mirror of
https://git.anonymousland.org/anonymousland/synapse.git
synced 2025-05-03 00:34:47 -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
16 changed files with 618 additions and 440 deletions
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue