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

This simplifies the access token verification logic by removing the `rights`
parameter which was only ever used for the unsubscribe link in email
notifications. The latter has been moved under the `/_synapse` namespace,
since it is not a standard API.

This also makes the email verification link more secure, by embedding the
app_id and pushkey in the macaroon and verifying it. This prevents the user
from tampering the query parameters of that unsubscribe link.

Macaroon generation is refactored:

- Centralised all macaroon generation and verification logic to the
  `MacaroonGenerator`
- Moved to `synapse.utils`
- Changed the constructor to require only a `Clock`, hostname, and a secret key
  (instead of a full `Homeserver`).
- Added tests for all methods.
This commit is contained in:
Quentin Gliech 2022-06-14 15:12:08 +02:00 committed by GitHub
parent 09a3c5ce0b
commit fe1daad672
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 618 additions and 440 deletions

1
changelog.d/12986.misc Normal file
View File

@ -0,0 +1 @@
Refactor macaroon tokens generation and move the unsubscribe link in notification emails to `/_synapse/client/unsubscribe`.

View File

@ -33,8 +33,6 @@ from synapse.http.site import SynapseRequest
from synapse.logging.opentracing import active_span, force_tracing, start_active_span
from synapse.storage.databases.main.registration import TokenLookupResult
from synapse.types import Requester, UserID, create_requester
from synapse.util.caches.lrucache import LruCache
from synapse.util.macaroons import get_value_from_macaroon, satisfy_expiry
if TYPE_CHECKING:
from synapse.server import HomeServer
@ -46,10 +44,6 @@ logger = logging.getLogger(__name__)
GUEST_DEVICE_ID = "guest_device"
class _InvalidMacaroonException(Exception):
pass
class Auth:
"""
This class contains functions for authenticating users of our client-server API.
@ -61,14 +55,10 @@ class Auth:
self.store = hs.get_datastores().main
self._account_validity_handler = hs.get_account_validity_handler()
self._storage_controllers = hs.get_storage_controllers()
self.token_cache: LruCache[str, Tuple[str, bool]] = LruCache(
10000, "token_cache"
)
self._macaroon_generator = hs.get_macaroon_generator()
self._track_appservice_user_ips = hs.config.appservice.track_appservice_user_ips
self._track_puppeted_user_ips = hs.config.api.track_puppeted_user_ips
self._macaroon_secret_key = hs.config.key.macaroon_secret_key
self._force_tracing_for_users = hs.config.tracing.force_tracing_for_users
async def check_user_in_room(
@ -123,7 +113,6 @@ class Auth:
self,
request: SynapseRequest,
allow_guest: bool = False,
rights: str = "access",
allow_expired: bool = False,
) -> Requester:
"""Get a registered user's ID.
@ -132,7 +121,6 @@ class Auth:
request: An HTTP request with an access_token query parameter.
allow_guest: If False, will raise an AuthError if the user making the
request is a guest.
rights: The operation being performed; the access token must allow this
allow_expired: If True, allow the request through even if the account
is expired, or session token lifetime has ended. Note that
/login will deliver access tokens regardless of expiration.
@ -147,7 +135,7 @@ class Auth:
parent_span = active_span()
with start_active_span("get_user_by_req"):
requester = await self._wrapped_get_user_by_req(
request, allow_guest, rights, allow_expired
request, allow_guest, allow_expired
)
if parent_span:
@ -173,7 +161,6 @@ class Auth:
self,
request: SynapseRequest,
allow_guest: bool,
rights: str,
allow_expired: bool,
) -> Requester:
"""Helper for get_user_by_req
@ -211,7 +198,7 @@ class Auth:
return requester
user_info = await self.get_user_by_access_token(
access_token, rights, allow_expired=allow_expired
access_token, allow_expired=allow_expired
)
token_id = user_info.token_id
is_guest = user_info.is_guest
@ -391,15 +378,12 @@ class Auth:
async def get_user_by_access_token(
self,
token: str,
rights: str = "access",
allow_expired: bool = False,
) -> TokenLookupResult:
"""Validate access token and get user_id from it
Args:
token: The access token to get the user by
rights: The operation being performed; the access token must
allow this
allow_expired: If False, raises an InvalidClientTokenError
if the token is expired
@ -410,7 +394,6 @@ class Auth:
is invalid
"""
if rights == "access":
# First look in the database to see if the access token is present
# as an opaque token.
r = await self.store.get_user_by_access_token(token)
@ -430,15 +413,9 @@ class Auth:
return r
# If the token isn't found in the database, then it could still be a
# macaroon, so we check that here.
# macaroon for a guest, so we check that here.
try:
user_id, guest = self._parse_and_validate_macaroon(token, rights)
if rights == "access":
if not guest:
# non-guest access tokens must be in the database
logger.warning("Unrecognised access token - not in store.")
raise InvalidClientTokenError()
user_id = self._macaroon_generator.verify_guest_token(token)
# Guest access tokens are not stored in the database (there can
# only be one access token per guest, anyway).
@ -459,21 +436,13 @@ class Auth:
"Guest access token used for regular user"
)
ret = TokenLookupResult(
return TokenLookupResult(
user_id=user_id,
is_guest=True,
# all guests get the same device id
device_id=GUEST_DEVICE_ID,
)
elif rights == "delete_pusher":
# We don't store these tokens in the database
ret = TokenLookupResult(user_id=user_id, is_guest=False)
else:
raise RuntimeError("Unknown rights setting %s", rights)
return ret
except (
_InvalidMacaroonException,
pymacaroons.exceptions.MacaroonException,
TypeError,
ValueError,
@ -485,78 +454,6 @@ class Auth:
)
raise InvalidClientTokenError("Invalid access token passed.")
def _parse_and_validate_macaroon(
self, token: str, rights: str = "access"
) -> Tuple[str, bool]:
"""Takes a macaroon and tries to parse and validate it. This is cached
if and only if rights == access and there isn't an expiry.
On invalid macaroon raises _InvalidMacaroonException
Returns:
(user_id, is_guest)
"""
if rights == "access":
cached = self.token_cache.get(token, None)
if cached:
return cached
try:
macaroon = pymacaroons.Macaroon.deserialize(token)
except Exception: # deserialize can throw more-or-less anything
# The access token doesn't look like a macaroon.
raise _InvalidMacaroonException()
try:
user_id = get_value_from_macaroon(macaroon, "user_id")
guest = False
for caveat in macaroon.caveats:
if caveat.caveat_id == "guest = true":
guest = True
self.validate_macaroon(macaroon, rights, user_id=user_id)
except (
pymacaroons.exceptions.MacaroonException,
KeyError,
TypeError,
ValueError,
):
raise InvalidClientTokenError("Invalid macaroon passed.")
if rights == "access":
self.token_cache[token] = (user_id, guest)
return user_id, guest
def validate_macaroon(
self, macaroon: pymacaroons.Macaroon, type_string: str, user_id: str
) -> None:
"""
validate that a Macaroon is understood by and was signed by this server.
Args:
macaroon: The macaroon to validate
type_string: The kind of token required (e.g. "access", "delete_pusher")
user_id: The user_id required
"""
v = pymacaroons.Verifier()
# the verifier runs a test for every caveat on the macaroon, to check
# that it is met for the current request. Each caveat must match at
# least one of the predicates specified by satisfy_exact or
# specify_general.
v.satisfy_exact("gen = 1")
v.satisfy_exact("type = " + type_string)
v.satisfy_exact("user_id = %s" % user_id)
v.satisfy_exact("guest = true")
satisfy_expiry(v, self.clock.time_msec)
# access_tokens include a nonce for uniqueness: any value is acceptable
v.satisfy_general(lambda c: c.startswith("nonce = "))
v.verify(macaroon, self._macaroon_secret_key)
def get_appservice_by_req(self, request: SynapseRequest) -> ApplicationService:
token = self.get_access_token_from_request(request)
service = self.store.get_app_service_by_token(token)

View File

@ -159,16 +159,18 @@ class KeyConfig(Config):
)
)
self.macaroon_secret_key = config.get(
macaroon_secret_key: Optional[str] = config.get(
"macaroon_secret_key", self.root.registration.registration_shared_secret
)
if not self.macaroon_secret_key:
if not macaroon_secret_key:
# Unfortunately, there are people out there that don't have this
# set. Lets just be "nice" and derive one from their secret key.
logger.warning("Config is missing macaroon_secret_key")
seed = bytes(self.signing_key[0])
self.macaroon_secret_key = hashlib.sha256(seed).digest()
else:
self.macaroon_secret_key = macaroon_secret_key.encode("utf-8")
# a secret which is used to calculate HMACs for form values, to stop
# falsification of values

View File

@ -37,9 +37,7 @@ from typing import (
import attr
import bcrypt
import pymacaroons
import unpaddedbase64
from pymacaroons.exceptions import MacaroonVerificationFailedException
from twisted.internet.defer import CancelledError
from twisted.web.server import Request
@ -69,7 +67,7 @@ from synapse.storage.roommember import ProfileInfo
from synapse.types import JsonDict, Requester, UserID
from synapse.util import stringutils as stringutils
from synapse.util.async_helpers import delay_cancellation, maybe_awaitable
from synapse.util.macaroons import get_value_from_macaroon, satisfy_expiry
from synapse.util.macaroons import LoginTokenAttributes
from synapse.util.msisdn import phone_number_to_msisdn
from synapse.util.stringutils import base62_encode
from synapse.util.threepids import canonicalise_email
@ -180,19 +178,6 @@ class SsoLoginExtraAttributes:
extra_attributes: JsonDict
@attr.s(slots=True, frozen=True, auto_attribs=True)
class LoginTokenAttributes:
"""Data we store in a short-term login token"""
user_id: str
auth_provider_id: str
"""The SSO Identity Provider that the user authenticated with, to get this token."""
auth_provider_session_id: Optional[str]
"""The session ID advertised by the SSO Identity Provider."""
class AuthHandler:
SESSION_EXPIRE_MS = 48 * 60 * 60 * 1000
@ -1831,98 +1816,6 @@ class AuthHandler:
return urllib.parse.urlunparse(url_parts)
@attr.s(slots=True, auto_attribs=True)
class MacaroonGenerator:
hs: "HomeServer"
def generate_guest_access_token(self, user_id: str) -> str:
macaroon = self._generate_base_macaroon(user_id)
macaroon.add_first_party_caveat("type = access")
# Include a nonce, to make sure that each login gets a different
# access token.
macaroon.add_first_party_caveat(
"nonce = %s" % (stringutils.random_string_with_symbols(16),)
)
macaroon.add_first_party_caveat("guest = true")
return macaroon.serialize()
def generate_short_term_login_token(
self,
user_id: str,
auth_provider_id: str,
auth_provider_session_id: Optional[str] = None,
duration_in_ms: int = (2 * 60 * 1000),
) -> str:
macaroon = self._generate_base_macaroon(user_id)
macaroon.add_first_party_caveat("type = login")
now = self.hs.get_clock().time_msec()
expiry = now + duration_in_ms
macaroon.add_first_party_caveat("time < %d" % (expiry,))
macaroon.add_first_party_caveat("auth_provider_id = %s" % (auth_provider_id,))
if auth_provider_session_id is not None:
macaroon.add_first_party_caveat(
"auth_provider_session_id = %s" % (auth_provider_session_id,)
)
return macaroon.serialize()
def verify_short_term_login_token(self, token: str) -> LoginTokenAttributes:
"""Verify a short-term-login macaroon
Checks that the given token is a valid, unexpired short-term-login token
minted by this server.
Args:
token: the login token to verify
Returns:
the user_id that this token is valid for
Raises:
MacaroonVerificationFailedException if the verification failed
"""
macaroon = pymacaroons.Macaroon.deserialize(token)
user_id = get_value_from_macaroon(macaroon, "user_id")
auth_provider_id = get_value_from_macaroon(macaroon, "auth_provider_id")
auth_provider_session_id: Optional[str] = None
try:
auth_provider_session_id = get_value_from_macaroon(
macaroon, "auth_provider_session_id"
)
except MacaroonVerificationFailedException:
pass
v = pymacaroons.Verifier()
v.satisfy_exact("gen = 1")
v.satisfy_exact("type = login")
v.satisfy_general(lambda c: c.startswith("user_id = "))
v.satisfy_general(lambda c: c.startswith("auth_provider_id = "))
v.satisfy_general(lambda c: c.startswith("auth_provider_session_id = "))
satisfy_expiry(v, self.hs.get_clock().time_msec)
v.verify(macaroon, self.hs.config.key.macaroon_secret_key)
return LoginTokenAttributes(
user_id=user_id,
auth_provider_id=auth_provider_id,
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 _generate_base_macaroon(self, user_id: str) -> pymacaroons.Macaroon:
macaroon = pymacaroons.Macaroon(
location=self.hs.config.server.server_name,
identifier="key",
key=self.hs.config.key.macaroon_secret_key,
)
macaroon.add_first_party_caveat("gen = 1")
macaroon.add_first_party_caveat("user_id = %s" % (user_id,))
return macaroon
def load_legacy_password_auth_providers(hs: "HomeServer") -> None:
module_api = hs.get_module_api()
for module, config in hs.config.authproviders.password_providers:

View File

@ -18,7 +18,6 @@ from typing import TYPE_CHECKING, Any, Dict, Generic, List, Optional, TypeVar, U
from urllib.parse import urlencode, urlparse
import attr
import pymacaroons
from authlib.common.security import generate_token
from authlib.jose import JsonWebToken, jwt
from authlib.oauth2.auth import ClientAuth
@ -44,7 +43,7 @@ from synapse.logging.context import make_deferred_yieldable
from synapse.types import JsonDict, UserID, map_username_to_mxid_localpart
from synapse.util import Clock, json_decoder
from synapse.util.caches.cached_call import RetryOnExceptionCachedCall
from synapse.util.macaroons import get_value_from_macaroon, satisfy_expiry
from synapse.util.macaroons import MacaroonGenerator, OidcSessionData
from synapse.util.templates import _localpart_from_email_filter
if TYPE_CHECKING:
@ -105,9 +104,10 @@ class OidcHandler:
# we should not have been instantiated if there is no configured provider.
assert provider_confs
self._token_generator = OidcSessionTokenGenerator(hs)
self._macaroon_generator = hs.get_macaroon_generator()
self._providers: Dict[str, "OidcProvider"] = {
p.idp_id: OidcProvider(hs, self._token_generator, p) for p in provider_confs
p.idp_id: OidcProvider(hs, self._macaroon_generator, p)
for p in provider_confs
}
async def load_metadata(self) -> None:
@ -216,7 +216,7 @@ class OidcHandler:
# Deserialize the session token and verify it.
try:
session_data = self._token_generator.verify_oidc_session_token(
session_data = self._macaroon_generator.verify_oidc_session_token(
session, state
)
except (MacaroonInitException, MacaroonDeserializationException, KeyError) as e:
@ -271,12 +271,12 @@ class OidcProvider:
def __init__(
self,
hs: "HomeServer",
token_generator: "OidcSessionTokenGenerator",
macaroon_generator: MacaroonGenerator,
provider: OidcProviderConfig,
):
self._store = hs.get_datastores().main
self._token_generator = token_generator
self._macaroon_generaton = macaroon_generator
self._config = provider
self._callback_url: str = hs.config.oidc.oidc_callback_url
@ -761,7 +761,7 @@ class OidcProvider:
if not client_redirect_url:
client_redirect_url = b""
cookie = self._token_generator.generate_oidc_session_token(
cookie = self._macaroon_generaton.generate_oidc_session_token(
state=state,
session_data=OidcSessionData(
idp_id=self.idp_id,
@ -1112,121 +1112,6 @@ class JwtClientSecret:
return self._cached_secret
class OidcSessionTokenGenerator:
"""Methods for generating and checking OIDC Session cookies."""
def __init__(self, hs: "HomeServer"):
self._clock = hs.get_clock()
self._server_name = hs.hostname
self._macaroon_secret_key = hs.config.key.macaroon_secret_key
def generate_oidc_session_token(
self,
state: str,
session_data: "OidcSessionData",
duration_in_ms: int = (60 * 60 * 1000),
) -> str:
"""Generates a signed token storing data about an OIDC session.
When Synapse initiates an authorization flow, it creates a random state
and a random nonce. Those parameters are given to the provider and
should be verified when the client comes back from the provider.
It is also used to store the client_redirect_url, which is used to
complete the SSO login flow.
Args:
state: The ``state`` parameter passed to the OIDC provider.
session_data: data to include in the session token.
duration_in_ms: An optional duration for the token in milliseconds.
Defaults to an hour.
Returns:
A signed macaroon token with the session information.
"""
macaroon = pymacaroons.Macaroon(
location=self._server_name,
identifier="key",
key=self._macaroon_secret_key,
)
macaroon.add_first_party_caveat("gen = 1")
macaroon.add_first_party_caveat("type = session")
macaroon.add_first_party_caveat("state = %s" % (state,))
macaroon.add_first_party_caveat("idp_id = %s" % (session_data.idp_id,))
macaroon.add_first_party_caveat("nonce = %s" % (session_data.nonce,))
macaroon.add_first_party_caveat(
"client_redirect_url = %s" % (session_data.client_redirect_url,)
)
macaroon.add_first_party_caveat(
"ui_auth_session_id = %s" % (session_data.ui_auth_session_id,)
)
now = self._clock.time_msec()
expiry = now + duration_in_ms
macaroon.add_first_party_caveat("time < %d" % (expiry,))
return macaroon.serialize()
def verify_oidc_session_token(
self, session: bytes, state: str
) -> "OidcSessionData":
"""Verifies and extract an OIDC session token.
This verifies that a given session token was issued by this homeserver
and extract the nonce and client_redirect_url caveats.
Args:
session: The session token to verify
state: The state the OIDC provider gave back
Returns:
The data extracted from the session cookie
Raises:
KeyError if an expected caveat is missing from the macaroon.
"""
macaroon = pymacaroons.Macaroon.deserialize(session)
v = pymacaroons.Verifier()
v.satisfy_exact("gen = 1")
v.satisfy_exact("type = session")
v.satisfy_exact("state = %s" % (state,))
v.satisfy_general(lambda c: c.startswith("nonce = "))
v.satisfy_general(lambda c: c.startswith("idp_id = "))
v.satisfy_general(lambda c: c.startswith("client_redirect_url = "))
v.satisfy_general(lambda c: c.startswith("ui_auth_session_id = "))
satisfy_expiry(v, self._clock.time_msec)
v.verify(macaroon, self._macaroon_secret_key)
# Extract the session data from the token.
nonce = get_value_from_macaroon(macaroon, "nonce")
idp_id = get_value_from_macaroon(macaroon, "idp_id")
client_redirect_url = get_value_from_macaroon(macaroon, "client_redirect_url")
ui_auth_session_id = get_value_from_macaroon(macaroon, "ui_auth_session_id")
return OidcSessionData(
nonce=nonce,
idp_id=idp_id,
client_redirect_url=client_redirect_url,
ui_auth_session_id=ui_auth_session_id,
)
@attr.s(frozen=True, slots=True, auto_attribs=True)
class OidcSessionData:
"""The attributes which are stored in a OIDC session cookie"""
# the Identity Provider being used
idp_id: str
# The `nonce` parameter passed to the OIDC provider.
nonce: str
# The URL the client gave when it initiated the flow. ("" if this is a UI Auth)
client_redirect_url: str
# The session ID of the ongoing UI Auth ("" if this is a login)
ui_auth_session_id: str
class UserAttributeDict(TypedDict):
localpart: Optional[str]
confirm_localpart: bool

View File

@ -860,13 +860,14 @@ class Mailer:
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),
)

View File

@ -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.
@ -15,17 +16,17 @@
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
from synapse.rest.client._base import client_patterns
from synapse.rest.synapse.client.unsubscribe import UnsubscribeResource
from synapse.types import JsonDict
if TYPE_CHECKING:
@ -132,48 +133,21 @@ class PushersSetRestServlet(RestServlet):
return 200, {}
class PushersRemoveRestServlet(RestServlet):
class LegacyPushersRemoveRestServlet(UnsubscribeResource, RestServlet):
"""
To allow pusher to be delete by clicking a link (ie. GET request)
A servlet to handle legacy "email unsubscribe" links, forwarding requests to the ``UnsubscribeResource``
This should be kept for some time, so unsubscribe links in past emails stay valid.
"""
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()
PATTERNS = client_patterns("/pushers/remove$", releases=[], v1=False, unstable=True)
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
# Forward the request to the UnsubscribeResource
await self._async_render(request)
def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
PushersRestServlet(hs).register(http_server)
PushersSetRestServlet(hs).register(http_server)
PushersRemoveRestServlet(hs).register(http_server)
LegacyPushersRemoveRestServlet(hs).register(http_server)

View File

@ -20,6 +20,7 @@ from synapse.rest.synapse.client.new_user_consent import NewUserConsentResource
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
@ -41,6 +42,8 @@ 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),
}
# provider-specific SSO bits. Only load these if they are enabled, since they

View File

@ -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)
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,
)

View File

@ -56,7 +56,7 @@ from synapse.handlers.account_data import AccountDataHandler
from synapse.handlers.account_validity import AccountValidityHandler
from synapse.handlers.admin import AdminHandler
from synapse.handlers.appservice import ApplicationServicesHandler
from synapse.handlers.auth import AuthHandler, MacaroonGenerator, PasswordAuthProvider
from synapse.handlers.auth import AuthHandler, PasswordAuthProvider
from synapse.handlers.cas import CasHandler
from synapse.handlers.deactivate_account import DeactivateAccountHandler
from synapse.handlers.device import DeviceHandler, DeviceWorkerHandler
@ -130,6 +130,7 @@ from synapse.streams.events import EventSources
from synapse.types import DomainSpecificString, ISynapseReactor
from synapse.util import Clock
from synapse.util.distributor import Distributor
from synapse.util.macaroons import MacaroonGenerator
from synapse.util.ratelimitutils import FederationRateLimiter
from synapse.util.stringutils import random_string
@ -492,7 +493,9 @@ class HomeServer(metaclass=abc.ABCMeta):
@cache_in_self
def get_macaroon_generator(self) -> MacaroonGenerator:
return MacaroonGenerator(self)
return MacaroonGenerator(
self.get_clock(), self.hostname, self.config.key.macaroon_secret_key
)
@cache_in_self
def get_device_handler(self):

View File

@ -17,8 +17,14 @@
from typing import Callable, Optional
import attr
import pymacaroons
from pymacaroons.exceptions import MacaroonVerificationFailedException
from typing_extensions import Literal
from synapse.util import Clock, stringutils
MacaroonType = Literal["access", "delete_pusher", "session", "login"]
def get_value_from_macaroon(macaroon: pymacaroons.Macaroon, key: str) -> str:
@ -86,3 +92,305 @@ def satisfy_expiry(v: pymacaroons.Verifier, get_time_ms: Callable[[], int]) -> N
return time_msec < expiry
v.satisfy_general(verify_expiry_caveat)
@attr.s(frozen=True, slots=True, auto_attribs=True)
class OidcSessionData:
"""The attributes which are stored in a OIDC session cookie"""
idp_id: str
"""The Identity Provider being used"""
nonce: str
"""The `nonce` parameter passed to the OIDC provider."""
client_redirect_url: str
"""The URL the client gave when it initiated the flow. ("" if this is a UI Auth)"""
ui_auth_session_id: str
"""The session ID of the ongoing UI Auth ("" if this is a login)"""
@attr.s(slots=True, frozen=True, auto_attribs=True)
class LoginTokenAttributes:
"""Data we store in a short-term login token"""
user_id: str
auth_provider_id: str
"""The SSO Identity Provider that the user authenticated with, to get this token."""
auth_provider_session_id: Optional[str]
"""The session ID advertised by the SSO Identity Provider."""
class MacaroonGenerator:
def __init__(self, clock: Clock, location: str, secret_key: bytes):
self._clock = clock
self._location = location
self._secret_key = secret_key
def generate_guest_access_token(self, user_id: str) -> str:
"""Generate a guest access token for the given user ID
Args:
user_id: The user ID for which the guest token should be generated.
Returns:
A signed access token for that guest user.
"""
nonce = stringutils.random_string_with_symbols(16)
macaroon = self._generate_base_macaroon("access")
macaroon.add_first_party_caveat(f"user_id = {user_id}")
macaroon.add_first_party_caveat(f"nonce = {nonce}")
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:
"""Generate a signed token used for unsubscribing from email notifications
Args:
user_id: The user for which this token will be valid.
app_id: The app_id for this pusher.
pushkey: The unique identifier of this pusher.
Returns:
A signed token which can be used in unsubscribe links.
"""
macaroon = self._generate_base_macaroon("delete_pusher")
macaroon.add_first_party_caveat(f"user_id = {user_id}")
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,
auth_provider_id: str,
auth_provider_session_id: Optional[str] = None,
duration_in_ms: int = (2 * 60 * 1000),
) -> str:
"""Generate a short-term login token used during SSO logins
Args:
user_id: The user for which the token is valid.
auth_provider_id: The SSO IdP the user used.
auth_provider_session_id: The session ID got during login from the SSO IdP.
Returns:
A signed token valid for using as a ``m.login.token`` token.
"""
now = self._clock.time_msec()
expiry = now + duration_in_ms
macaroon = self._generate_base_macaroon("login")
macaroon.add_first_party_caveat(f"user_id = {user_id}")
macaroon.add_first_party_caveat(f"time < {expiry}")
macaroon.add_first_party_caveat(f"auth_provider_id = {auth_provider_id}")
if auth_provider_session_id is not None:
macaroon.add_first_party_caveat(
f"auth_provider_session_id = {auth_provider_session_id}"
)
return macaroon.serialize()
def generate_oidc_session_token(
self,
state: str,
session_data: OidcSessionData,
duration_in_ms: int = (60 * 60 * 1000),
) -> str:
"""Generates a signed token storing data about an OIDC session.
When Synapse initiates an authorization flow, it creates a random state
and a random nonce. Those parameters are given to the provider and
should be verified when the client comes back from the provider.
It is also used to store the client_redirect_url, which is used to
complete the SSO login flow.
Args:
state: The ``state`` parameter passed to the OIDC provider.
session_data: data to include in the session token.
duration_in_ms: An optional duration for the token in milliseconds.
Defaults to an hour.
Returns:
A signed macaroon token with the session information.
"""
now = self._clock.time_msec()
expiry = now + duration_in_ms
macaroon = self._generate_base_macaroon("session")
macaroon.add_first_party_caveat(f"state = {state}")
macaroon.add_first_party_caveat(f"idp_id = {session_data.idp_id}")
macaroon.add_first_party_caveat(f"nonce = {session_data.nonce}")
macaroon.add_first_party_caveat(
f"client_redirect_url = {session_data.client_redirect_url}"
)
macaroon.add_first_party_caveat(
f"ui_auth_session_id = {session_data.ui_auth_session_id}"
)
macaroon.add_first_party_caveat(f"time < {expiry}")
return macaroon.serialize()
def verify_short_term_login_token(self, token: str) -> LoginTokenAttributes:
"""Verify a short-term-login macaroon
Checks that the given token is a valid, unexpired short-term-login token
minted by this server.
Args:
token: The login token to verify.
Returns:
A set of attributes carried by this token, including the
``user_id`` and informations about the SSO IDP used during that
login.
Raises:
MacaroonVerificationFailedException if the verification failed
"""
macaroon = pymacaroons.Macaroon.deserialize(token)
v = self._base_verifier("login")
v.satisfy_general(lambda c: c.startswith("user_id = "))
v.satisfy_general(lambda c: c.startswith("auth_provider_id = "))
v.satisfy_general(lambda c: c.startswith("auth_provider_session_id = "))
satisfy_expiry(v, self._clock.time_msec)
v.verify(macaroon, self._secret_key)
user_id = get_value_from_macaroon(macaroon, "user_id")
auth_provider_id = get_value_from_macaroon(macaroon, "auth_provider_id")
auth_provider_session_id: Optional[str] = None
try:
auth_provider_session_id = get_value_from_macaroon(
macaroon, "auth_provider_session_id"
)
except MacaroonVerificationFailedException:
pass
return LoginTokenAttributes(
user_id=user_id,
auth_provider_id=auth_provider_id,
auth_provider_session_id=auth_provider_session_id,
)
def verify_guest_token(self, token: str) -> str:
"""Verify a guest access token macaroon
Checks that the given token is a valid, unexpired guest access token
minted by this server.
Args:
token: The access token to verify.
Returns:
The ``user_id`` that this token is valid for.
Raises:
MacaroonVerificationFailedException if the verification failed
"""
macaroon = pymacaroons.Macaroon.deserialize(token)
user_id = get_value_from_macaroon(macaroon, "user_id")
# At some point, Synapse would generate macaroons without the "guest"
# caveat for regular users. Because of how macaroon verification works,
# to avoid validating those as guest tokens, we explicitely verify if
# the macaroon includes the "guest = true" caveat.
is_guest = any(
(caveat.caveat_id == "guest = true" for caveat in macaroon.caveats)
)
if not is_guest:
raise MacaroonVerificationFailedException("Macaroon is not a guest token")
v = self._base_verifier("access")
v.satisfy_exact("guest = true")
v.satisfy_general(lambda c: c.startswith("user_id = "))
v.satisfy_general(lambda c: c.startswith("nonce = "))
satisfy_expiry(v, self._clock.time_msec)
v.verify(macaroon, self._secret_key)
return user_id
def verify_delete_pusher_token(self, token: str, app_id: str, pushkey: str) -> str:
"""Verify a token from an email unsubscribe link
Args:
token: The token to verify.
app_id: The app_id of the pusher to delete.
pushkey: The unique identifier of the pusher to delete.
Return:
The ``user_id`` for which this token is valid.
Raises:
MacaroonVerificationFailedException if the verification failed
"""
macaroon = pymacaroons.Macaroon.deserialize(token)
user_id = get_value_from_macaroon(macaroon, "user_id")
v = self._base_verifier("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._secret_key)
return user_id
def verify_oidc_session_token(self, session: bytes, state: str) -> OidcSessionData:
"""Verifies and extract an OIDC session token.
This verifies that a given session token was issued by this homeserver
and extract the nonce and client_redirect_url caveats.
Args:
session: The session token to verify
state: The state the OIDC provider gave back
Returns:
The data extracted from the session cookie
Raises:
KeyError if an expected caveat is missing from the macaroon.
"""
macaroon = pymacaroons.Macaroon.deserialize(session)
v = self._base_verifier("session")
v.satisfy_exact(f"state = {state}")
v.satisfy_general(lambda c: c.startswith("nonce = "))
v.satisfy_general(lambda c: c.startswith("idp_id = "))
v.satisfy_general(lambda c: c.startswith("client_redirect_url = "))
v.satisfy_general(lambda c: c.startswith("ui_auth_session_id = "))
satisfy_expiry(v, self._clock.time_msec)
v.verify(macaroon, self._secret_key)
# Extract the session data from the token.
nonce = get_value_from_macaroon(macaroon, "nonce")
idp_id = get_value_from_macaroon(macaroon, "idp_id")
client_redirect_url = get_value_from_macaroon(macaroon, "client_redirect_url")
ui_auth_session_id = get_value_from_macaroon(macaroon, "ui_auth_session_id")
return OidcSessionData(
nonce=nonce,
idp_id=idp_id,
client_redirect_url=client_redirect_url,
ui_auth_session_id=ui_auth_session_id,
)
def _generate_base_macaroon(self, type: MacaroonType) -> pymacaroons.Macaroon:
macaroon = pymacaroons.Macaroon(
location=self._location,
identifier="key",
key=self._secret_key,
)
macaroon.add_first_party_caveat("gen = 1")
macaroon.add_first_party_caveat(f"type = {type}")
return macaroon
def _base_verifier(self, type: MacaroonType) -> pymacaroons.Verifier:
v = pymacaroons.Verifier()
v.satisfy_exact("gen = 1")
v.satisfy_exact(f"type = {type}")
return v

View File

@ -313,9 +313,7 @@ class AuthTestCase(unittest.HomeserverTestCase):
self.assertEqual(self.store.insert_client_ip.call_count, 2)
def test_get_user_from_macaroon(self):
self.store.get_user_by_access_token = simple_async_mock(
TokenLookupResult(user_id="@baldrick:matrix.org", device_id="device")
)
self.store.get_user_by_access_token = simple_async_mock(None)
user_id = "@baldrick:matrix.org"
macaroon = pymacaroons.Macaroon(
@ -323,17 +321,14 @@ class AuthTestCase(unittest.HomeserverTestCase):
identifier="key",
key=self.hs.config.key.macaroon_secret_key,
)
# "Legacy" macaroons should not work for regular users not in the database
macaroon.add_first_party_caveat("gen = 1")
macaroon.add_first_party_caveat("type = access")
macaroon.add_first_party_caveat("user_id = %s" % (user_id,))
user_info = self.get_success(
self.auth.get_user_by_access_token(macaroon.serialize())
serialized = macaroon.serialize()
self.get_failure(
self.auth.get_user_by_access_token(serialized), InvalidClientTokenError
)
self.assertEqual(user_id, user_info.user_id)
# TODO: device_id should come from the macaroon, but currently comes
# from the db.
self.assertEqual(user_info.device_id, "device")
def test_get_guest_user_from_macaroon(self):
self.store.get_user_by_id = simple_async_mock({"is_guest": True})

View File

@ -25,7 +25,7 @@ from synapse.handlers.sso import MappingException
from synapse.server import HomeServer
from synapse.types import JsonDict, UserID
from synapse.util import Clock
from synapse.util.macaroons import get_value_from_macaroon
from synapse.util.macaroons import OidcSessionData, get_value_from_macaroon
from tests.test_utils import FakeResponse, get_awaitable_result, simple_async_mock
from tests.unittest import HomeserverTestCase, override_config
@ -1227,7 +1227,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
) -> str:
from synapse.handlers.oidc import OidcSessionData
return self.handler._token_generator.generate_oidc_session_token(
return self.handler._macaroon_generator.generate_oidc_session_token(
state=state,
session_data=OidcSessionData(
idp_id="oidc",
@ -1251,7 +1251,6 @@ async def _make_callback_with_userinfo(
userinfo: the OIDC userinfo dict
client_redirect_url: the URL to redirect to on success.
"""
from synapse.handlers.oidc import OidcSessionData
handler = hs.get_oidc_handler()
provider = handler._providers["oidc"]
@ -1260,7 +1259,7 @@ async def _make_callback_with_userinfo(
provider._fetch_userinfo = simple_async_mock(return_value=userinfo) # type: ignore[assignment]
state = "state"
session = handler._token_generator.generate_oidc_session_token(
session = handler._macaroon_generator.generate_oidc_session_token(
state=state,
session_data=OidcSessionData(
idp_id="oidc",

View File

@ -11,7 +11,7 @@
# 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 Collection, Dict, List, Optional
from typing import Collection, Dict, List, Optional, cast
from unittest.mock import Mock
from twisted.internet import defer
@ -22,6 +22,8 @@ from synapse.api.room_versions import RoomVersions
from synapse.events import make_event_from_dict
from synapse.events.snapshot import EventContext
from synapse.state import StateHandler, StateResolutionHandler
from synapse.util import Clock
from synapse.util.macaroons import MacaroonGenerator
from tests import unittest
@ -190,13 +192,18 @@ class StateTestCase(unittest.TestCase):
"get_clock",
"get_state_resolution_handler",
"get_account_validity_handler",
"get_macaroon_generator",
"hostname",
]
)
clock = cast(Clock, MockClock())
hs.config = default_config("tesths", True)
hs.get_datastores.return_value = Mock(main=self.dummy_store)
hs.get_state_handler.return_value = None
hs.get_clock.return_value = MockClock()
hs.get_clock.return_value = clock
hs.get_macaroon_generator.return_value = MacaroonGenerator(
clock, "tesths", b"verysecret"
)
hs.get_auth.return_value = Auth(hs)
hs.get_state_resolution_handler = lambda: StateResolutionHandler(hs)
hs.get_storage_controllers.return_value = storage_controllers

View File

@ -315,7 +315,7 @@ class HomeserverTestCase(TestCase):
"is_guest": False,
}
async def get_user_by_req(request, allow_guest=False, rights="access"):
async def get_user_by_req(request, allow_guest=False):
assert self.helper.auth_user_id is not None
return create_requester(
UserID.from_string(self.helper.auth_user_id),

View File

@ -0,0 +1,146 @@
# 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 pymacaroons.exceptions import MacaroonVerificationFailedException
from synapse.util.macaroons import MacaroonGenerator, OidcSessionData
from tests.server import get_clock
from tests.unittest import TestCase
class MacaroonGeneratorTestCase(TestCase):
def setUp(self):
self.reactor, hs_clock = get_clock()
self.macaroon_generator = MacaroonGenerator(hs_clock, "tesths", b"verysecret")
self.other_macaroon_generator = MacaroonGenerator(
hs_clock, "tesths", b"anothersecretkey"
)
def test_guest_access_token(self):
"""Test the generation and verification of guest access tokens"""
token = self.macaroon_generator.generate_guest_access_token("@user:tesths")
user_id = self.macaroon_generator.verify_guest_token(token)
self.assertEqual(user_id, "@user:tesths")
# Raises with another secret key
with self.assertRaises(MacaroonVerificationFailedException):
self.other_macaroon_generator.verify_guest_token(token)
# Check that an old access token without the guest caveat does not work
macaroon = self.macaroon_generator._generate_base_macaroon("access")
macaroon.add_first_party_caveat(f"user_id = {user_id}")
macaroon.add_first_party_caveat("nonce = 0123456789abcdef")
token = macaroon.serialize()
with self.assertRaises(MacaroonVerificationFailedException):
self.macaroon_generator.verify_guest_token(token)
def test_delete_pusher_token(self):
"""Test the generation and verification of delete_pusher tokens"""
token = self.macaroon_generator.generate_delete_pusher_token(
"@user:tesths", "m.mail", "john@example.com"
)
user_id = self.macaroon_generator.verify_delete_pusher_token(
token, "m.mail", "john@example.com"
)
self.assertEqual(user_id, "@user:tesths")
# Raises with another secret key
with self.assertRaises(MacaroonVerificationFailedException):
self.other_macaroon_generator.verify_delete_pusher_token(
token, "m.mail", "john@example.com"
)
# Raises when verifying for another pushkey
with self.assertRaises(MacaroonVerificationFailedException):
self.macaroon_generator.verify_delete_pusher_token(
token, "m.mail", "other@example.com"
)
# Raises when verifying for another app_id
with self.assertRaises(MacaroonVerificationFailedException):
self.macaroon_generator.verify_delete_pusher_token(
token, "somethingelse", "john@example.com"
)
# Check that an old token without the app_id and pushkey still works
macaroon = self.macaroon_generator._generate_base_macaroon("delete_pusher")
macaroon.add_first_party_caveat("user_id = @user:tesths")
token = macaroon.serialize()
user_id = self.macaroon_generator.verify_delete_pusher_token(
token, "m.mail", "john@example.com"
)
self.assertEqual(user_id, "@user:tesths")
def test_short_term_login_token(self):
"""Test the generation and verification of short-term login tokens"""
token = self.macaroon_generator.generate_short_term_login_token(
user_id="@user:tesths",
auth_provider_id="oidc",
auth_provider_session_id="sid",
duration_in_ms=2 * 60 * 1000,
)
info = self.macaroon_generator.verify_short_term_login_token(token)
self.assertEqual(info.user_id, "@user:tesths")
self.assertEqual(info.auth_provider_id, "oidc")
self.assertEqual(info.auth_provider_session_id, "sid")
# Raises with another secret key
with self.assertRaises(MacaroonVerificationFailedException):
self.other_macaroon_generator.verify_short_term_login_token(token)
# Wait a minute
self.reactor.pump([60])
# Shouldn't raise
self.macaroon_generator.verify_short_term_login_token(token)
# Wait another minute
self.reactor.pump([60])
# Should raise since it expired
with self.assertRaises(MacaroonVerificationFailedException):
self.macaroon_generator.verify_short_term_login_token(token)
def test_oidc_session_token(self):
"""Test the generation and verification of OIDC session cookies"""
state = "arandomstate"
session_data = OidcSessionData(
idp_id="oidc",
nonce="nonce",
client_redirect_url="https://example.com/",
ui_auth_session_id="",
)
token = self.macaroon_generator.generate_oidc_session_token(
state, session_data, duration_in_ms=2 * 60 * 1000
).encode("utf-8")
info = self.macaroon_generator.verify_oidc_session_token(token, state)
self.assertEqual(session_data, info)
# Raises with another secret key
with self.assertRaises(MacaroonVerificationFailedException):
self.other_macaroon_generator.verify_oidc_session_token(token, state)
# Should raise with another state
with self.assertRaises(MacaroonVerificationFailedException):
self.macaroon_generator.verify_oidc_session_token(token, "anotherstate")
# Wait a minute
self.reactor.pump([60])
# Shouldn't raise
self.macaroon_generator.verify_oidc_session_token(token, state)
# Wait another minute
self.reactor.pump([60])
# Should raise since it expired
with self.assertRaises(MacaroonVerificationFailedException):
self.macaroon_generator.verify_oidc_session_token(token, state)