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.logging.opentracing import active_span, force_tracing, start_active_span
from synapse.storage.databases.main.registration import TokenLookupResult from synapse.storage.databases.main.registration import TokenLookupResult
from synapse.types import Requester, UserID, create_requester 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: if TYPE_CHECKING:
from synapse.server import HomeServer from synapse.server import HomeServer
@ -46,10 +44,6 @@ logger = logging.getLogger(__name__)
GUEST_DEVICE_ID = "guest_device" GUEST_DEVICE_ID = "guest_device"
class _InvalidMacaroonException(Exception):
pass
class Auth: class Auth:
""" """
This class contains functions for authenticating users of our client-server API. 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.store = hs.get_datastores().main
self._account_validity_handler = hs.get_account_validity_handler() self._account_validity_handler = hs.get_account_validity_handler()
self._storage_controllers = hs.get_storage_controllers() self._storage_controllers = hs.get_storage_controllers()
self._macaroon_generator = hs.get_macaroon_generator()
self.token_cache: LruCache[str, Tuple[str, bool]] = LruCache(
10000, "token_cache"
)
self._track_appservice_user_ips = hs.config.appservice.track_appservice_user_ips 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._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 self._force_tracing_for_users = hs.config.tracing.force_tracing_for_users
async def check_user_in_room( async def check_user_in_room(
@ -123,7 +113,6 @@ class Auth:
self, self,
request: SynapseRequest, request: SynapseRequest,
allow_guest: bool = False, allow_guest: bool = False,
rights: str = "access",
allow_expired: bool = False, allow_expired: bool = False,
) -> Requester: ) -> Requester:
"""Get a registered user's ID. """Get a registered user's ID.
@ -132,7 +121,6 @@ class Auth:
request: An HTTP request with an access_token query parameter. request: An HTTP request with an access_token query parameter.
allow_guest: If False, will raise an AuthError if the user making the allow_guest: If False, will raise an AuthError if the user making the
request is a guest. 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 allow_expired: If True, allow the request through even if the account
is expired, or session token lifetime has ended. Note that is expired, or session token lifetime has ended. Note that
/login will deliver access tokens regardless of expiration. /login will deliver access tokens regardless of expiration.
@ -147,7 +135,7 @@ class Auth:
parent_span = active_span() parent_span = active_span()
with start_active_span("get_user_by_req"): with start_active_span("get_user_by_req"):
requester = await self._wrapped_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: if parent_span:
@ -173,7 +161,6 @@ class Auth:
self, self,
request: SynapseRequest, request: SynapseRequest,
allow_guest: bool, allow_guest: bool,
rights: str,
allow_expired: bool, allow_expired: bool,
) -> Requester: ) -> Requester:
"""Helper for get_user_by_req """Helper for get_user_by_req
@ -211,7 +198,7 @@ class Auth:
return requester return requester
user_info = await self.get_user_by_access_token( 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 token_id = user_info.token_id
is_guest = user_info.is_guest is_guest = user_info.is_guest
@ -391,15 +378,12 @@ class Auth:
async def get_user_by_access_token( async def get_user_by_access_token(
self, self,
token: str, token: str,
rights: str = "access",
allow_expired: bool = False, allow_expired: bool = False,
) -> TokenLookupResult: ) -> TokenLookupResult:
"""Validate access token and get user_id from it """Validate access token and get user_id from it
Args: Args:
token: The access token to get the user by 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 allow_expired: If False, raises an InvalidClientTokenError
if the token is expired if the token is expired
@ -410,7 +394,6 @@ class Auth:
is invalid is invalid
""" """
if rights == "access":
# First look in the database to see if the access token is present # First look in the database to see if the access token is present
# as an opaque token. # as an opaque token.
r = await self.store.get_user_by_access_token(token) r = await self.store.get_user_by_access_token(token)
@ -430,15 +413,9 @@ class Auth:
return r return r
# If the token isn't found in the database, then it could still be a # 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: try:
user_id, guest = self._parse_and_validate_macaroon(token, rights) user_id = self._macaroon_generator.verify_guest_token(token)
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()
# Guest access tokens are not stored in the database (there can # Guest access tokens are not stored in the database (there can
# only be one access token per guest, anyway). # only be one access token per guest, anyway).
@ -459,21 +436,13 @@ class Auth:
"Guest access token used for regular user" "Guest access token used for regular user"
) )
ret = TokenLookupResult( return TokenLookupResult(
user_id=user_id, user_id=user_id,
is_guest=True, is_guest=True,
# all guests get the same device id # all guests get the same device id
device_id=GUEST_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 ( except (
_InvalidMacaroonException,
pymacaroons.exceptions.MacaroonException, pymacaroons.exceptions.MacaroonException,
TypeError, TypeError,
ValueError, ValueError,
@ -485,78 +454,6 @@ class Auth:
) )
raise InvalidClientTokenError("Invalid access token passed.") 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: def get_appservice_by_req(self, request: SynapseRequest) -> ApplicationService:
token = self.get_access_token_from_request(request) token = self.get_access_token_from_request(request)
service = self.store.get_app_service_by_token(token) 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 "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 # Unfortunately, there are people out there that don't have this
# set. Lets just be "nice" and derive one from their secret key. # set. Lets just be "nice" and derive one from their secret key.
logger.warning("Config is missing macaroon_secret_key") logger.warning("Config is missing macaroon_secret_key")
seed = bytes(self.signing_key[0]) seed = bytes(self.signing_key[0])
self.macaroon_secret_key = hashlib.sha256(seed).digest() 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 # a secret which is used to calculate HMACs for form values, to stop
# falsification of values # falsification of values

View file

@ -37,9 +37,7 @@ from typing import (
import attr import attr
import bcrypt import bcrypt
import pymacaroons
import unpaddedbase64 import unpaddedbase64
from pymacaroons.exceptions import MacaroonVerificationFailedException
from twisted.internet.defer import CancelledError from twisted.internet.defer import CancelledError
from twisted.web.server import Request 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.types import JsonDict, Requester, UserID
from synapse.util import stringutils as stringutils from synapse.util import stringutils as stringutils
from synapse.util.async_helpers import delay_cancellation, maybe_awaitable 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.msisdn import phone_number_to_msisdn
from synapse.util.stringutils import base62_encode from synapse.util.stringutils import base62_encode
from synapse.util.threepids import canonicalise_email from synapse.util.threepids import canonicalise_email
@ -180,19 +178,6 @@ class SsoLoginExtraAttributes:
extra_attributes: JsonDict 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: class AuthHandler:
SESSION_EXPIRE_MS = 48 * 60 * 60 * 1000 SESSION_EXPIRE_MS = 48 * 60 * 60 * 1000
@ -1831,98 +1816,6 @@ class AuthHandler:
return urllib.parse.urlunparse(url_parts) 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: def load_legacy_password_auth_providers(hs: "HomeServer") -> None:
module_api = hs.get_module_api() module_api = hs.get_module_api()
for module, config in hs.config.authproviders.password_providers: 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 from urllib.parse import urlencode, urlparse
import attr import attr
import pymacaroons
from authlib.common.security import generate_token from authlib.common.security import generate_token
from authlib.jose import JsonWebToken, jwt from authlib.jose import JsonWebToken, jwt
from authlib.oauth2.auth import ClientAuth 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.types import JsonDict, UserID, map_username_to_mxid_localpart
from synapse.util import Clock, json_decoder from synapse.util import Clock, json_decoder
from synapse.util.caches.cached_call import RetryOnExceptionCachedCall 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 from synapse.util.templates import _localpart_from_email_filter
if TYPE_CHECKING: if TYPE_CHECKING:
@ -105,9 +104,10 @@ class OidcHandler:
# we should not have been instantiated if there is no configured provider. # we should not have been instantiated if there is no configured provider.
assert provider_confs assert provider_confs
self._token_generator = OidcSessionTokenGenerator(hs) self._macaroon_generator = hs.get_macaroon_generator()
self._providers: Dict[str, "OidcProvider"] = { 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: async def load_metadata(self) -> None:
@ -216,7 +216,7 @@ class OidcHandler:
# Deserialize the session token and verify it. # Deserialize the session token and verify it.
try: try:
session_data = self._token_generator.verify_oidc_session_token( session_data = self._macaroon_generator.verify_oidc_session_token(
session, state session, state
) )
except (MacaroonInitException, MacaroonDeserializationException, KeyError) as e: except (MacaroonInitException, MacaroonDeserializationException, KeyError) as e:
@ -271,12 +271,12 @@ class OidcProvider:
def __init__( def __init__(
self, self,
hs: "HomeServer", hs: "HomeServer",
token_generator: "OidcSessionTokenGenerator", macaroon_generator: MacaroonGenerator,
provider: OidcProviderConfig, provider: OidcProviderConfig,
): ):
self._store = hs.get_datastores().main self._store = hs.get_datastores().main
self._token_generator = token_generator self._macaroon_generaton = macaroon_generator
self._config = provider self._config = provider
self._callback_url: str = hs.config.oidc.oidc_callback_url self._callback_url: str = hs.config.oidc.oidc_callback_url
@ -761,7 +761,7 @@ class OidcProvider:
if not client_redirect_url: if not client_redirect_url:
client_redirect_url = b"" client_redirect_url = b""
cookie = self._token_generator.generate_oidc_session_token( cookie = self._macaroon_generaton.generate_oidc_session_token(
state=state, state=state,
session_data=OidcSessionData( session_data=OidcSessionData(
idp_id=self.idp_id, idp_id=self.idp_id,
@ -1112,121 +1112,6 @@ class JwtClientSecret:
return self._cached_secret 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): class UserAttributeDict(TypedDict):
localpart: Optional[str] localpart: Optional[str]
confirm_localpart: bool confirm_localpart: bool

View file

@ -860,13 +860,14 @@ class Mailer:
A link to unsubscribe from email notifications. A link to unsubscribe from email notifications.
""" """
params = { 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, "app_id": app_id,
"pushkey": email_address, "pushkey": email_address,
} }
# XXX: make r0 once API is stable return "%s_synapse/client/unsubscribe?%s" % (
return "%s_matrix/client/unstable/pushers/remove?%s" % (
self.hs.config.server.public_baseurl, self.hs.config.server.public_baseurl,
urllib.parse.urlencode(params), urllib.parse.urlencode(params),
) )

View file

@ -1,4 +1,5 @@
# Copyright 2014-2016 OpenMarket Ltd # Copyright 2014-2016 OpenMarket Ltd
# Copyright 2022 The Matrix.org Foundation C.I.C.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -15,17 +16,17 @@
import logging import logging
from typing import TYPE_CHECKING, Tuple from typing import TYPE_CHECKING, Tuple
from synapse.api.errors import Codes, StoreError, SynapseError from synapse.api.errors import Codes, SynapseError
from synapse.http.server import HttpServer, respond_with_html_bytes from synapse.http.server import HttpServer
from synapse.http.servlet import ( from synapse.http.servlet import (
RestServlet, RestServlet,
assert_params_in_dict, assert_params_in_dict,
parse_json_object_from_request, parse_json_object_from_request,
parse_string,
) )
from synapse.http.site import SynapseRequest from synapse.http.site import SynapseRequest
from synapse.push import PusherConfigException from synapse.push import PusherConfigException
from synapse.rest.client._base import client_patterns from synapse.rest.client._base import client_patterns
from synapse.rest.synapse.client.unsubscribe import UnsubscribeResource
from synapse.types import JsonDict from synapse.types import JsonDict
if TYPE_CHECKING: if TYPE_CHECKING:
@ -132,48 +133,21 @@ class PushersSetRestServlet(RestServlet):
return 200, {} 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) PATTERNS = client_patterns("/pushers/remove$", releases=[], v1=False, unstable=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: async def on_GET(self, request: SynapseRequest) -> None:
requester = await self.auth.get_user_by_req(request, rights="delete_pusher") # Forward the request to the UnsubscribeResource
user = requester.user await self._async_render(request)
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: def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
PushersRestServlet(hs).register(http_server) PushersRestServlet(hs).register(http_server)
PushersSetRestServlet(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_idp import PickIdpResource
from synapse.rest.synapse.client.pick_username import pick_username_resource 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.sso_register import SsoRegisterResource
from synapse.rest.synapse.client.unsubscribe import UnsubscribeResource
if TYPE_CHECKING: if TYPE_CHECKING:
from synapse.server import HomeServer 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/pick_username": pick_username_resource(hs),
"/_synapse/client/new_user_consent": NewUserConsentResource(hs), "/_synapse/client/new_user_consent": NewUserConsentResource(hs),
"/_synapse/client/sso_register": SsoRegisterResource(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 # 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.account_validity import AccountValidityHandler
from synapse.handlers.admin import AdminHandler from synapse.handlers.admin import AdminHandler
from synapse.handlers.appservice import ApplicationServicesHandler 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.cas import CasHandler
from synapse.handlers.deactivate_account import DeactivateAccountHandler from synapse.handlers.deactivate_account import DeactivateAccountHandler
from synapse.handlers.device import DeviceHandler, DeviceWorkerHandler 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.types import DomainSpecificString, ISynapseReactor
from synapse.util import Clock from synapse.util import Clock
from synapse.util.distributor import Distributor from synapse.util.distributor import Distributor
from synapse.util.macaroons import MacaroonGenerator
from synapse.util.ratelimitutils import FederationRateLimiter from synapse.util.ratelimitutils import FederationRateLimiter
from synapse.util.stringutils import random_string from synapse.util.stringutils import random_string
@ -492,7 +493,9 @@ class HomeServer(metaclass=abc.ABCMeta):
@cache_in_self @cache_in_self
def get_macaroon_generator(self) -> MacaroonGenerator: 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 @cache_in_self
def get_device_handler(self): def get_device_handler(self):

View file

@ -17,8 +17,14 @@
from typing import Callable, Optional from typing import Callable, Optional
import attr
import pymacaroons import pymacaroons
from pymacaroons.exceptions import MacaroonVerificationFailedException 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: 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 return time_msec < expiry
v.satisfy_general(verify_expiry_caveat) 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) self.assertEqual(self.store.insert_client_ip.call_count, 2)
def test_get_user_from_macaroon(self): def test_get_user_from_macaroon(self):
self.store.get_user_by_access_token = simple_async_mock( self.store.get_user_by_access_token = simple_async_mock(None)
TokenLookupResult(user_id="@baldrick:matrix.org", device_id="device")
)
user_id = "@baldrick:matrix.org" user_id = "@baldrick:matrix.org"
macaroon = pymacaroons.Macaroon( macaroon = pymacaroons.Macaroon(
@ -323,17 +321,14 @@ class AuthTestCase(unittest.HomeserverTestCase):
identifier="key", identifier="key",
key=self.hs.config.key.macaroon_secret_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("gen = 1")
macaroon.add_first_party_caveat("type = access") macaroon.add_first_party_caveat("type = access")
macaroon.add_first_party_caveat("user_id = %s" % (user_id,)) macaroon.add_first_party_caveat("user_id = %s" % (user_id,))
user_info = self.get_success( serialized = macaroon.serialize()
self.auth.get_user_by_access_token(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): def test_get_guest_user_from_macaroon(self):
self.store.get_user_by_id = simple_async_mock({"is_guest": True}) 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.server import HomeServer
from synapse.types import JsonDict, UserID from synapse.types import JsonDict, UserID
from synapse.util import Clock 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.test_utils import FakeResponse, get_awaitable_result, simple_async_mock
from tests.unittest import HomeserverTestCase, override_config from tests.unittest import HomeserverTestCase, override_config
@ -1227,7 +1227,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
) -> str: ) -> str:
from synapse.handlers.oidc import OidcSessionData 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, state=state,
session_data=OidcSessionData( session_data=OidcSessionData(
idp_id="oidc", idp_id="oidc",
@ -1251,7 +1251,6 @@ async def _make_callback_with_userinfo(
userinfo: the OIDC userinfo dict userinfo: the OIDC userinfo dict
client_redirect_url: the URL to redirect to on success. client_redirect_url: the URL to redirect to on success.
""" """
from synapse.handlers.oidc import OidcSessionData
handler = hs.get_oidc_handler() handler = hs.get_oidc_handler()
provider = handler._providers["oidc"] 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] provider._fetch_userinfo = simple_async_mock(return_value=userinfo) # type: ignore[assignment]
state = "state" state = "state"
session = handler._token_generator.generate_oidc_session_token( session = handler._macaroon_generator.generate_oidc_session_token(
state=state, state=state,
session_data=OidcSessionData( session_data=OidcSessionData(
idp_id="oidc", idp_id="oidc",

View file

@ -11,7 +11,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # 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 unittest.mock import Mock
from twisted.internet import defer 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 import make_event_from_dict
from synapse.events.snapshot import EventContext from synapse.events.snapshot import EventContext
from synapse.state import StateHandler, StateResolutionHandler from synapse.state import StateHandler, StateResolutionHandler
from synapse.util import Clock
from synapse.util.macaroons import MacaroonGenerator
from tests import unittest from tests import unittest
@ -190,13 +192,18 @@ class StateTestCase(unittest.TestCase):
"get_clock", "get_clock",
"get_state_resolution_handler", "get_state_resolution_handler",
"get_account_validity_handler", "get_account_validity_handler",
"get_macaroon_generator",
"hostname", "hostname",
] ]
) )
clock = cast(Clock, MockClock())
hs.config = default_config("tesths", True) hs.config = default_config("tesths", True)
hs.get_datastores.return_value = Mock(main=self.dummy_store) hs.get_datastores.return_value = Mock(main=self.dummy_store)
hs.get_state_handler.return_value = None 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_auth.return_value = Auth(hs)
hs.get_state_resolution_handler = lambda: StateResolutionHandler(hs) hs.get_state_resolution_handler = lambda: StateResolutionHandler(hs)
hs.get_storage_controllers.return_value = storage_controllers hs.get_storage_controllers.return_value = storage_controllers

View file

@ -315,7 +315,7 @@ class HomeserverTestCase(TestCase):
"is_guest": False, "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 assert self.helper.auth_user_id is not None
return create_requester( return create_requester(
UserID.from_string(self.helper.auth_user_id), 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)