Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Move the "email unsubscribe" resource, refactor the macaroon generator & simplify the access token verification logic #12986

Merged
merged 16 commits into from
Jun 14, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
27 changes: 23 additions & 4 deletions synapse/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -1845,6 +1845,15 @@ def generate_guest_access_token(self, user_id: str) -> str:
macaroon.add_first_party_caveat("guest = true")
return macaroon.serialize()

def generate_delete_pusher_token(
self, user_id: str, app_id: str, pushkey: str
) -> str:
macaroon = self._generate_base_macaroon(user_id)
macaroon.add_first_party_caveat("type = delete_pusher")
macaroon.add_first_party_caveat(f"app_id = {app_id}")
macaroon.add_first_party_caveat(f"pushkey = {pushkey}")
return macaroon.serialize()

def generate_short_term_login_token(
self,
user_id: str,
Expand Down Expand Up @@ -1906,10 +1915,20 @@ def verify_short_term_login_token(self, token: str) -> LoginTokenAttributes:
auth_provider_session_id=auth_provider_session_id,
)

def generate_delete_pusher_token(self, user_id: str) -> str:
macaroon = self._generate_base_macaroon(user_id)
macaroon.add_first_party_caveat("type = delete_pusher")
return macaroon.serialize()

def verify_delete_pusher_token(self, token: str, app_id: str, pushkey: str) -> str:
macaroon = pymacaroons.Macaroon.deserialize(token)
user_id = get_value_from_macaroon(macaroon, "user_id")

v = pymacaroons.Verifier()
v.satisfy_exact("gen = 1")
v.satisfy_exact("type = delete_pusher")
v.satisfy_exact(f"app_id = {app_id}")
v.satisfy_exact(f"pushkey = {pushkey}")
v.satisfy_general(lambda c: c.startswith("user_id = "))
v.verify(macaroon, self.hs.config.key.macaroon_secret_key)

return user_id

def _generate_base_macaroon(self, user_id: str) -> pymacaroons.Macaroon:
macaroon = pymacaroons.Macaroon(
Expand Down
7 changes: 4 additions & 3 deletions synapse/push/mailer.py
Original file line number Diff line number Diff line change
Expand Up @@ -860,13 +860,14 @@ def _make_unsubscribe_link(
A link to unsubscribe from email notifications.
"""
params = {
"access_token": self.macaroon_gen.generate_delete_pusher_token(user_id),
"access_token": self.macaroon_gen.generate_delete_pusher_token(
user_id, app_id, email_address
),
"app_id": app_id,
"pushkey": email_address,
}

# XXX: make r0 once API is stable
return "%s_matrix/client/unstable/pushers/remove?%s" % (
return "%s_synapse/client/unsubscribe?%s" % (
self.hs.config.server.public_baseurl,
urllib.parse.urlencode(params),
)
Expand Down
48 changes: 3 additions & 45 deletions synapse/rest/client/pusher.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Copyright 2014-2016 OpenMarket Ltd
# Copyright 2022 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand All @@ -15,13 +16,12 @@
import logging
from typing import TYPE_CHECKING, Tuple

from synapse.api.errors import Codes, StoreError, SynapseError
from synapse.http.server import HttpServer, respond_with_html_bytes
from synapse.api.errors import Codes, SynapseError
from synapse.http.server import HttpServer
from synapse.http.servlet import (
RestServlet,
assert_params_in_dict,
parse_json_object_from_request,
parse_string,
)
from synapse.http.site import SynapseRequest
from synapse.push import PusherConfigException
Expand Down Expand Up @@ -132,48 +132,6 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
return 200, {}


class PushersRemoveRestServlet(RestServlet):
"""
To allow pusher to be delete by clicking a link (ie. GET request)
"""

PATTERNS = client_patterns("/pushers/remove$", v1=True)
SUCCESS_HTML = b"<html><body>You have been unsubscribed</body><html>"

def __init__(self, hs: "HomeServer"):
super().__init__()
self.hs = hs
self.notifier = hs.get_notifier()
self.auth = hs.get_auth()
self.pusher_pool = self.hs.get_pusherpool()

async def on_GET(self, request: SynapseRequest) -> None:
requester = await self.auth.get_user_by_req(request, rights="delete_pusher")
user = requester.user

app_id = parse_string(request, "app_id", required=True)
pushkey = parse_string(request, "pushkey", required=True)

try:
await self.pusher_pool.remove_pusher(
app_id=app_id, pushkey=pushkey, user_id=user.to_string()
)
except StoreError as se:
if se.code != 404:
# This is fine: they're already unsubscribed
raise

self.notifier.on_new_replication_data()

respond_with_html_bytes(
request,
200,
PushersRemoveRestServlet.SUCCESS_HTML,
)
return None


def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
PushersRestServlet(hs).register(http_server)
PushersSetRestServlet(hs).register(http_server)
PushersRemoveRestServlet(hs).register(http_server)
5 changes: 5 additions & 0 deletions synapse/rest/synapse/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from synapse.rest.synapse.client.pick_idp import PickIdpResource
from synapse.rest.synapse.client.pick_username import pick_username_resource
from synapse.rest.synapse.client.sso_register import SsoRegisterResource
from synapse.rest.synapse.client.unsubscribe import UnsubscribeResource

if TYPE_CHECKING:
from synapse.server import HomeServer
Expand All @@ -41,6 +42,10 @@ def build_synapse_client_resource_tree(hs: "HomeServer") -> Mapping[str, Resourc
"/_synapse/client/pick_username": pick_username_resource(hs),
"/_synapse/client/new_user_consent": NewUserConsentResource(hs),
"/_synapse/client/sso_register": SsoRegisterResource(hs),
# Unsubscribe to notification emails link
"/_synapse/client/unsubscribe": UnsubscribeResource(hs),
# Legacy endpoint
"/_matrix/client/unstable/pushers/remove": UnsubscribeResource(hs),
}

# provider-specific SSO bits. Only load these if they are enabled, since they
Expand Down
64 changes: 64 additions & 0 deletions synapse/rest/synapse/client/unsubscribe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Copyright 2022 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from typing import TYPE_CHECKING

from synapse.api.errors import StoreError
from synapse.http.server import DirectServeHtmlResource, respond_with_html_bytes
from synapse.http.servlet import parse_string
from synapse.http.site import SynapseRequest

if TYPE_CHECKING:
from synapse.server import HomeServer


class UnsubscribeResource(DirectServeHtmlResource):
"""
To allow pusher to be delete by clicking a link (ie. GET request)
"""

SUCCESS_HTML = b"<html><body>You have been unsubscribed</body><html>"

def __init__(self, hs: "HomeServer"):
super().__init__()
self.notifier = hs.get_notifier()
self.auth = hs.get_auth()
self.pusher_pool = hs.get_pusherpool()
self.macaroon_generator = hs.get_macaroon_generator()

async def _async_render_GET(self, request: SynapseRequest) -> None:
token = parse_string(request, "access_token", required=True)
app_id = parse_string(request, "app_id", required=True)
pushkey = parse_string(request, "pushkey", required=True)
clokep marked this conversation as resolved.
Show resolved Hide resolved

user_id = self.macaroon_generator.verify_delete_pusher_token(
token, app_id, pushkey
)

try:
await self.pusher_pool.remove_pusher(
app_id=app_id, pushkey=pushkey, user_id=user_id
)
except StoreError as se:
if se.code != 404:
# This is fine: they're already unsubscribed
raise

self.notifier.on_new_replication_data()

respond_with_html_bytes(
request,
200,
UnsubscribeResource.SUCCESS_HTML,
)