mirror of
https://git.anonymousland.org/anonymousland/synapse.git
synced 2024-12-28 12:49:24 -05: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.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,70 +394,55 @@ 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)
|
if r:
|
||||||
if r:
|
valid_until_ms = r.valid_until_ms
|
||||||
valid_until_ms = r.valid_until_ms
|
if (
|
||||||
if (
|
not allow_expired
|
||||||
not allow_expired
|
and valid_until_ms is not None
|
||||||
and valid_until_ms is not None
|
and valid_until_ms < self.clock.time_msec()
|
||||||
and valid_until_ms < self.clock.time_msec()
|
):
|
||||||
):
|
# there was a valid access token, but it has expired.
|
||||||
# there was a valid access token, but it has expired.
|
# soft-logout the user.
|
||||||
# soft-logout the user.
|
raise InvalidClientTokenError(
|
||||||
raise InvalidClientTokenError(
|
msg="Access token has expired", soft_logout=True
|
||||||
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
|
# 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":
|
# Guest access tokens are not stored in the database (there can
|
||||||
if not guest:
|
# only be one access token per guest, anyway).
|
||||||
# non-guest access tokens must be in the database
|
#
|
||||||
logger.warning("Unrecognised access token - not in store.")
|
# In order to prevent guest access tokens being used as regular
|
||||||
raise InvalidClientTokenError()
|
# user access tokens (and hence getting around the invalidation
|
||||||
|
# process), we look up the user id and check that it is indeed
|
||||||
# Guest access tokens are not stored in the database (there can
|
# a guest user.
|
||||||
# only be one access token per guest, anyway).
|
#
|
||||||
#
|
# It would of course be much easier to store guest access
|
||||||
# In order to prevent guest access tokens being used as regular
|
# tokens in the database as well, but that would break existing
|
||||||
# user access tokens (and hence getting around the invalidation
|
# guest tokens.
|
||||||
# process), we look up the user id and check that it is indeed
|
stored_user = await self.store.get_user_by_id(user_id)
|
||||||
# a guest user.
|
if not stored_user:
|
||||||
#
|
raise InvalidClientTokenError("Unknown user_id %s" % user_id)
|
||||||
# It would of course be much easier to store guest access
|
if not stored_user["is_guest"]:
|
||||||
# tokens in the database as well, but that would break existing
|
raise InvalidClientTokenError(
|
||||||
# guest tokens.
|
"Guest access token used for regular user"
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
elif rights == "delete_pusher":
|
|
||||||
# We don't store these tokens in the database
|
|
||||||
|
|
||||||
ret = TokenLookupResult(user_id=user_id, is_guest=False)
|
return TokenLookupResult(
|
||||||
else:
|
user_id=user_id,
|
||||||
raise RuntimeError("Unknown rights setting %s", rights)
|
is_guest=True,
|
||||||
return ret
|
# all guests get the same device id
|
||||||
|
device_id=GUEST_DEVICE_ID,
|
||||||
|
)
|
||||||
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)
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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),
|
||||||
)
|
)
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
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.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):
|
||||||
|
@ -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
|
||||||
|
@ -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})
|
||||||
|
@ -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",
|
||||||
|
@ -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
|
||||||
|
@ -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),
|
||||||
|
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