Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit fe1daad

Browse files
authored
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.
1 parent 09a3c5c commit fe1daad

File tree

16 files changed

+619
-441
lines changed

16 files changed

+619
-441
lines changed

changelog.d/12986.misc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Refactor macaroon tokens generation and move the unsubscribe link in notification emails to `/_synapse/client/unsubscribe`.

synapse/api/auth.py

Lines changed: 45 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,6 @@
3333
from synapse.logging.opentracing import active_span, force_tracing, start_active_span
3434
from synapse.storage.databases.main.registration import TokenLookupResult
3535
from synapse.types import Requester, UserID, create_requester
36-
from synapse.util.caches.lrucache import LruCache
37-
from synapse.util.macaroons import get_value_from_macaroon, satisfy_expiry
3836

3937
if TYPE_CHECKING:
4038
from synapse.server import HomeServer
@@ -46,10 +44,6 @@
4644
GUEST_DEVICE_ID = "guest_device"
4745

4846

49-
class _InvalidMacaroonException(Exception):
50-
pass
51-
52-
5347
class Auth:
5448
"""
5549
This class contains functions for authenticating users of our client-server API.
@@ -61,14 +55,10 @@ def __init__(self, hs: "HomeServer"):
6155
self.store = hs.get_datastores().main
6256
self._account_validity_handler = hs.get_account_validity_handler()
6357
self._storage_controllers = hs.get_storage_controllers()
64-
65-
self.token_cache: LruCache[str, Tuple[str, bool]] = LruCache(
66-
10000, "token_cache"
67-
)
58+
self._macaroon_generator = hs.get_macaroon_generator()
6859

6960
self._track_appservice_user_ips = hs.config.appservice.track_appservice_user_ips
7061
self._track_puppeted_user_ips = hs.config.api.track_puppeted_user_ips
71-
self._macaroon_secret_key = hs.config.key.macaroon_secret_key
7262
self._force_tracing_for_users = hs.config.tracing.force_tracing_for_users
7363

7464
async def check_user_in_room(
@@ -123,7 +113,6 @@ async def get_user_by_req(
123113
self,
124114
request: SynapseRequest,
125115
allow_guest: bool = False,
126-
rights: str = "access",
127116
allow_expired: bool = False,
128117
) -> Requester:
129118
"""Get a registered user's ID.
@@ -132,7 +121,6 @@ async def get_user_by_req(
132121
request: An HTTP request with an access_token query parameter.
133122
allow_guest: If False, will raise an AuthError if the user making the
134123
request is a guest.
135-
rights: The operation being performed; the access token must allow this
136124
allow_expired: If True, allow the request through even if the account
137125
is expired, or session token lifetime has ended. Note that
138126
/login will deliver access tokens regardless of expiration.
@@ -147,7 +135,7 @@ async def get_user_by_req(
147135
parent_span = active_span()
148136
with start_active_span("get_user_by_req"):
149137
requester = await self._wrapped_get_user_by_req(
150-
request, allow_guest, rights, allow_expired
138+
request, allow_guest, allow_expired
151139
)
152140

153141
if parent_span:
@@ -173,7 +161,6 @@ async def _wrapped_get_user_by_req(
173161
self,
174162
request: SynapseRequest,
175163
allow_guest: bool,
176-
rights: str,
177164
allow_expired: bool,
178165
) -> Requester:
179166
"""Helper for get_user_by_req
@@ -211,7 +198,7 @@ async def _wrapped_get_user_by_req(
211198
return requester
212199

213200
user_info = await self.get_user_by_access_token(
214-
access_token, rights, allow_expired=allow_expired
201+
access_token, allow_expired=allow_expired
215202
)
216203
token_id = user_info.token_id
217204
is_guest = user_info.is_guest
@@ -391,15 +378,12 @@ async def _get_appservice_user_id_and_device_id(
391378
async def get_user_by_access_token(
392379
self,
393380
token: str,
394-
rights: str = "access",
395381
allow_expired: bool = False,
396382
) -> TokenLookupResult:
397383
"""Validate access token and get user_id from it
398384
399385
Args:
400386
token: The access token to get the user by
401-
rights: The operation being performed; the access token must
402-
allow this
403387
allow_expired: If False, raises an InvalidClientTokenError
404388
if the token is expired
405389
@@ -410,70 +394,55 @@ async def get_user_by_access_token(
410394
is invalid
411395
"""
412396

413-
if rights == "access":
414-
# First look in the database to see if the access token is present
415-
# as an opaque token.
416-
r = await self.store.get_user_by_access_token(token)
417-
if r:
418-
valid_until_ms = r.valid_until_ms
419-
if (
420-
not allow_expired
421-
and valid_until_ms is not None
422-
and valid_until_ms < self.clock.time_msec()
423-
):
424-
# there was a valid access token, but it has expired.
425-
# soft-logout the user.
426-
raise InvalidClientTokenError(
427-
msg="Access token has expired", soft_logout=True
428-
)
397+
# First look in the database to see if the access token is present
398+
# as an opaque token.
399+
r = await self.store.get_user_by_access_token(token)
400+
if r:
401+
valid_until_ms = r.valid_until_ms
402+
if (
403+
not allow_expired
404+
and valid_until_ms is not None
405+
and valid_until_ms < self.clock.time_msec()
406+
):
407+
# there was a valid access token, but it has expired.
408+
# soft-logout the user.
409+
raise InvalidClientTokenError(
410+
msg="Access token has expired", soft_logout=True
411+
)
429412

430-
return r
413+
return r
431414

432415
# If the token isn't found in the database, then it could still be a
433-
# macaroon, so we check that here.
416+
# macaroon for a guest, so we check that here.
434417
try:
435-
user_id, guest = self._parse_and_validate_macaroon(token, rights)
436-
437-
if rights == "access":
438-
if not guest:
439-
# non-guest access tokens must be in the database
440-
logger.warning("Unrecognised access token - not in store.")
441-
raise InvalidClientTokenError()
442-
443-
# Guest access tokens are not stored in the database (there can
444-
# only be one access token per guest, anyway).
445-
#
446-
# In order to prevent guest access tokens being used as regular
447-
# user access tokens (and hence getting around the invalidation
448-
# process), we look up the user id and check that it is indeed
449-
# a guest user.
450-
#
451-
# It would of course be much easier to store guest access
452-
# tokens in the database as well, but that would break existing
453-
# guest tokens.
454-
stored_user = await self.store.get_user_by_id(user_id)
455-
if not stored_user:
456-
raise InvalidClientTokenError("Unknown user_id %s" % user_id)
457-
if not stored_user["is_guest"]:
458-
raise InvalidClientTokenError(
459-
"Guest access token used for regular user"
460-
)
461-
462-
ret = TokenLookupResult(
463-
user_id=user_id,
464-
is_guest=True,
465-
# all guests get the same device id
466-
device_id=GUEST_DEVICE_ID,
418+
user_id = self._macaroon_generator.verify_guest_token(token)
419+
420+
# Guest access tokens are not stored in the database (there can
421+
# only be one access token per guest, anyway).
422+
#
423+
# In order to prevent guest access tokens being used as regular
424+
# user access tokens (and hence getting around the invalidation
425+
# process), we look up the user id and check that it is indeed
426+
# a guest user.
427+
#
428+
# It would of course be much easier to store guest access
429+
# tokens in the database as well, but that would break existing
430+
# guest tokens.
431+
stored_user = await self.store.get_user_by_id(user_id)
432+
if not stored_user:
433+
raise InvalidClientTokenError("Unknown user_id %s" % user_id)
434+
if not stored_user["is_guest"]:
435+
raise InvalidClientTokenError(
436+
"Guest access token used for regular user"
467437
)
468-
elif rights == "delete_pusher":
469-
# We don't store these tokens in the database
470438

471-
ret = TokenLookupResult(user_id=user_id, is_guest=False)
472-
else:
473-
raise RuntimeError("Unknown rights setting %s", rights)
474-
return ret
439+
return TokenLookupResult(
440+
user_id=user_id,
441+
is_guest=True,
442+
# all guests get the same device id
443+
device_id=GUEST_DEVICE_ID,
444+
)
475445
except (
476-
_InvalidMacaroonException,
477446
pymacaroons.exceptions.MacaroonException,
478447
TypeError,
479448
ValueError,
@@ -485,78 +454,6 @@ async def get_user_by_access_token(
485454
)
486455
raise InvalidClientTokenError("Invalid access token passed.")
487456

488-
def _parse_and_validate_macaroon(
489-
self, token: str, rights: str = "access"
490-
) -> Tuple[str, bool]:
491-
"""Takes a macaroon and tries to parse and validate it. This is cached
492-
if and only if rights == access and there isn't an expiry.
493-
494-
On invalid macaroon raises _InvalidMacaroonException
495-
496-
Returns:
497-
(user_id, is_guest)
498-
"""
499-
if rights == "access":
500-
cached = self.token_cache.get(token, None)
501-
if cached:
502-
return cached
503-
504-
try:
505-
macaroon = pymacaroons.Macaroon.deserialize(token)
506-
except Exception: # deserialize can throw more-or-less anything
507-
# The access token doesn't look like a macaroon.
508-
raise _InvalidMacaroonException()
509-
510-
try:
511-
user_id = get_value_from_macaroon(macaroon, "user_id")
512-
513-
guest = False
514-
for caveat in macaroon.caveats:
515-
if caveat.caveat_id == "guest = true":
516-
guest = True
517-
518-
self.validate_macaroon(macaroon, rights, user_id=user_id)
519-
except (
520-
pymacaroons.exceptions.MacaroonException,
521-
KeyError,
522-
TypeError,
523-
ValueError,
524-
):
525-
raise InvalidClientTokenError("Invalid macaroon passed.")
526-
527-
if rights == "access":
528-
self.token_cache[token] = (user_id, guest)
529-
530-
return user_id, guest
531-
532-
def validate_macaroon(
533-
self, macaroon: pymacaroons.Macaroon, type_string: str, user_id: str
534-
) -> None:
535-
"""
536-
validate that a Macaroon is understood by and was signed by this server.
537-
538-
Args:
539-
macaroon: The macaroon to validate
540-
type_string: The kind of token required (e.g. "access", "delete_pusher")
541-
user_id: The user_id required
542-
"""
543-
v = pymacaroons.Verifier()
544-
545-
# the verifier runs a test for every caveat on the macaroon, to check
546-
# that it is met for the current request. Each caveat must match at
547-
# least one of the predicates specified by satisfy_exact or
548-
# specify_general.
549-
v.satisfy_exact("gen = 1")
550-
v.satisfy_exact("type = " + type_string)
551-
v.satisfy_exact("user_id = %s" % user_id)
552-
v.satisfy_exact("guest = true")
553-
satisfy_expiry(v, self.clock.time_msec)
554-
555-
# access_tokens include a nonce for uniqueness: any value is acceptable
556-
v.satisfy_general(lambda c: c.startswith("nonce = "))
557-
558-
v.verify(macaroon, self._macaroon_secret_key)
559-
560457
def get_appservice_by_req(self, request: SynapseRequest) -> ApplicationService:
561458
token = self.get_access_token_from_request(request)
562459
service = self.store.get_app_service_by_token(token)

synapse/config/key.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,16 +159,18 @@ def read_config(
159159
)
160160
)
161161

162-
self.macaroon_secret_key = config.get(
162+
macaroon_secret_key: Optional[str] = config.get(
163163
"macaroon_secret_key", self.root.registration.registration_shared_secret
164164
)
165165

166-
if not self.macaroon_secret_key:
166+
if not macaroon_secret_key:
167167
# Unfortunately, there are people out there that don't have this
168168
# set. Lets just be "nice" and derive one from their secret key.
169169
logger.warning("Config is missing macaroon_secret_key")
170170
seed = bytes(self.signing_key[0])
171171
self.macaroon_secret_key = hashlib.sha256(seed).digest()
172+
else:
173+
self.macaroon_secret_key = macaroon_secret_key.encode("utf-8")
172174

173175
# a secret which is used to calculate HMACs for form values, to stop
174176
# falsification of values

0 commit comments

Comments
 (0)