mirror of
https://mau.dev/maunium/synapse.git
synced 2024-10-01 01:36:05 -04:00
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:
parent
09a3c5ce0b
commit
fe1daad672
1
changelog.d/12986.misc
Normal file
1
changelog.d/12986.misc
Normal file
@ -0,0 +1 @@
|
||||
Refactor macaroon tokens generation and move the unsubscribe link in notification emails to `/_synapse/client/unsubscribe`.
|
@ -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,70 +394,55 @@ 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)
|
||||
if r:
|
||||
valid_until_ms = r.valid_until_ms
|
||||
if (
|
||||
not allow_expired
|
||||
and valid_until_ms is not None
|
||||
and valid_until_ms < self.clock.time_msec()
|
||||
):
|
||||
# there was a valid access token, but it has expired.
|
||||
# soft-logout the user.
|
||||
raise InvalidClientTokenError(
|
||||
msg="Access token has expired", soft_logout=True
|
||||
)
|
||||
# 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)
|
||||
if r:
|
||||
valid_until_ms = r.valid_until_ms
|
||||
if (
|
||||
not allow_expired
|
||||
and valid_until_ms is not None
|
||||
and valid_until_ms < self.clock.time_msec()
|
||||
):
|
||||
# there was a valid access token, but it has expired.
|
||||
# soft-logout the user.
|
||||
raise InvalidClientTokenError(
|
||||
msg="Access token has expired", soft_logout=True
|
||||
)
|
||||
|
||||
return r
|
||||
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)
|
||||
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
|
||||
# only be one access token per guest, anyway).
|
||||
#
|
||||
# In order to prevent guest access tokens being used as regular
|
||||
# user access tokens (and hence getting around the invalidation
|
||||
# process), we look up the user id and check that it is indeed
|
||||
# a guest user.
|
||||
#
|
||||
# It would of course be much easier to store guest access
|
||||
# tokens in the database as well, but that would break existing
|
||||
# guest tokens.
|
||||
stored_user = await self.store.get_user_by_id(user_id)
|
||||
if not stored_user:
|
||||
raise InvalidClientTokenError("Unknown user_id %s" % user_id)
|
||||
if not stored_user["is_guest"]:
|
||||
raise InvalidClientTokenError(
|
||||
"Guest access token used for regular user"
|
||||
)
|
||||
|
||||
ret = TokenLookupResult(
|
||||
user_id=user_id,
|
||||
is_guest=True,
|
||||
# all guests get the same device id
|
||||
device_id=GUEST_DEVICE_ID,
|
||||
# Guest access tokens are not stored in the database (there can
|
||||
# only be one access token per guest, anyway).
|
||||
#
|
||||
# In order to prevent guest access tokens being used as regular
|
||||
# user access tokens (and hence getting around the invalidation
|
||||
# process), we look up the user id and check that it is indeed
|
||||
# a guest user.
|
||||
#
|
||||
# It would of course be much easier to store guest access
|
||||
# tokens in the database as well, but that would break existing
|
||||
# guest tokens.
|
||||
stored_user = await self.store.get_user_by_id(user_id)
|
||||
if not stored_user:
|
||||
raise InvalidClientTokenError("Unknown user_id %s" % user_id)
|
||||
if not stored_user["is_guest"]:
|
||||
raise InvalidClientTokenError(
|
||||
"Guest access token used for regular user"
|
||||
)
|
||||
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
|
||||
return TokenLookupResult(
|
||||
user_id=user_id,
|
||||
is_guest=True,
|
||||
# all guests get the same device id
|
||||
device_id=GUEST_DEVICE_ID,
|
||||
)
|
||||
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)
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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),
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
64
synapse/rest/synapse/client/unsubscribe.py
Normal file
64
synapse/rest/synapse/client/unsubscribe.py
Normal 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,
|
||||
)
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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})
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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),
|
||||
|
146
tests/util/test_macaroons.py
Normal file
146
tests/util/test_macaroons.py
Normal 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)
|
Loading…
Reference in New Issue
Block a user