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

Change the format of access tokens away from macaroons #5588

Merged
merged 15 commits into from
May 12, 2021
1 change: 1 addition & 0 deletions changelog.d/5588.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Reduce the length of Synapse's access tokens.
2 changes: 1 addition & 1 deletion scripts-dev/dump_macaroon.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env python2
#!/usr/bin/env python

import sys

Expand Down
28 changes: 21 additions & 7 deletions synapse/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import time
import unicodedata
import urllib.parse
from binascii import crc32
from typing import (
TYPE_CHECKING,
Any,
Expand All @@ -34,6 +35,7 @@
import attr
import bcrypt
import pymacaroons
import unpaddedbase64

from twisted.web.server import Request

Expand Down Expand Up @@ -66,6 +68,7 @@
from synapse.util.async_helpers import maybe_awaitable
from synapse.util.macaroons import get_value_from_macaroon, satisfy_expiry
from synapse.util.msisdn import phone_number_to_msisdn
from synapse.util.stringutils import base62_encode
from synapse.util.threepids import canonicalise_email

if TYPE_CHECKING:
Expand Down Expand Up @@ -808,18 +811,20 @@ async def get_access_token_for_user_id(
logger.info(
"Logging in user %s as %s%s", user_id, puppets_user_id, fmt_expiry
)
target_user_id_obj = UserID.from_string(puppets_user_id)
else:
logger.info(
"Logging in user %s on device %s%s", user_id, device_id, fmt_expiry
)
target_user_id_obj = UserID.from_string(user_id)

if (
not is_appservice_ghost
or self.hs.config.appservice.track_appservice_user_ips
):
await self.auth.check_auth_blocking(user_id)

access_token = self.macaroon_gen.generate_access_token(user_id)
access_token = self.generate_access_token(target_user_id_obj)
await self.store.add_access_token_to_user(
user_id=user_id,
token=access_token,
Expand Down Expand Up @@ -1192,6 +1197,19 @@ async def _check_local_password(self, user_id: str, password: str) -> Optional[s
return None
return user_id

def generate_access_token(self, for_user: UserID) -> str:
"""Generates an opaque string, for use as an access token"""

# we use the following format for access tokens:
# syt_<base64 local part>_<random string>_<base62 crc check>

b64local = unpaddedbase64.encode_base64(for_user.localpart.encode("utf-8"))
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a thought occurs. The use of base64 here means that access tokens will have to be correctly url-encoded when used in a query param.

maybe we should use a url-safe base64 encoding.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, hmm. Good point.

I had a quick look at haproxy support and it looks like b64dec only handles standard padded base64 (2.4 will have support for padded url safe base64 decoding). Don't that is a huge concern though, since I think conversion is relatively easy with simple string replacement?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

of course, we could just say that only people with spec-compliant mxids are allowed to use matrix, so it doesn't need encoding at all 😈

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love it :D

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this needs to particularly influence us; we should be deprecating and removing GET based access token use, as it's just a bad idea for http access logs outside of synapse, perhaps the effort of ensuring your access tokens are urlencoded correctly will push developers towards doing the right thing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's fair. it's really not that hard to do it right, even from a shell command.

random_string = stringutils.random_string(20)
base = f"syt_{b64local}_{random_string}"

crc = base62_encode(crc32(base.encode("ascii")), minwidth=6)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we using base62 for the crc? Is that just how its typically done?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wfm

return f"{base}_{crc}"

async def validate_short_term_login_token(
self, login_token: str
) -> LoginTokenAttributes:
Expand Down Expand Up @@ -1585,19 +1603,15 @@ class MacaroonGenerator:

hs = attr.ib()

def generate_access_token(
self, user_id: str, extra_caveats: Optional[List[str]] = None
) -> str:
extra_caveats = extra_caveats or []
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),)
)
for caveat in extra_caveats:
macaroon.add_first_party_caveat(caveat)
macaroon.add_first_party_caveat("guest = true")
return macaroon.serialize()

def generate_short_term_login_token(
Expand Down
4 changes: 1 addition & 3 deletions synapse/handlers/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -722,9 +722,7 @@ class and RegisterDeviceReplicationServlet.
)
if is_guest:
assert valid_until_ms is None
access_token = self.macaroon_gen.generate_access_token(
user_id, ["guest = true"]
)
access_token = self.macaroon_gen.generate_guest_access_token(user_id)
else:
access_token = await self._auth_handler.get_access_token_for_user_id(
user_id,
Expand Down
20 changes: 20 additions & 0 deletions synapse/util/stringutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,23 @@ def strtobool(val: str) -> bool:
return False
else:
raise ValueError("invalid truth value %r" % (val,))


_BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"


def base62_encode(num: int, minwidth: int = 1) -> str:
"""Encode a number using base62

Args:
num: number to be encoded
minwidth: width to pad to, if the number is small
"""
res = ""
while num:
num, rem = divmod(num, 62)
res = _BASE62[rem] + res

# pad to minimum width
pad = "0" * (minwidth - len(res))
return pad + res
63 changes: 0 additions & 63 deletions tests/api/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,11 @@
from synapse.api.errors import (
AuthError,
Codes,
InvalidClientCredentialsError,
InvalidClientTokenError,
MissingClientTokenError,
ResourceLimitError,
)
from synapse.storage.databases.main.registration import TokenLookupResult
from synapse.types import UserID

from tests import unittest
from tests.test_utils import simple_async_mock
Expand Down Expand Up @@ -253,67 +251,6 @@ def test_get_guest_user_from_macaroon(self):
self.assertTrue(user_info.is_guest)
self.store.get_user_by_id.assert_called_with(user_id)

def test_cannot_use_regular_token_as_guest(self):
USER_ID = "@percy:matrix.org"
self.store.add_access_token_to_user = simple_async_mock(None)
self.store.get_device = simple_async_mock(None)

token = self.get_success(
self.hs.get_auth_handler().get_access_token_for_user_id(
USER_ID, "DEVICE", valid_until_ms=None
)
)
self.store.add_access_token_to_user.assert_called_with(
user_id=USER_ID,
token=token,
device_id="DEVICE",
valid_until_ms=None,
puppets_user_id=None,
)

async def get_user(tok):
if token != tok:
return None
return TokenLookupResult(
user_id=USER_ID,
is_guest=False,
token_id=1234,
device_id="DEVICE",
)

self.store.get_user_by_access_token = get_user
self.store.get_user_by_id = simple_async_mock({"is_guest": False})

# check the token works
request = Mock(args={})
request.args[b"access_token"] = [token.encode("ascii")]
request.requestHeaders.getRawHeaders = mock_getRawHeaders()
requester = self.get_success(
self.auth.get_user_by_req(request, allow_guest=True)
)
self.assertEqual(UserID.from_string(USER_ID), requester.user)
self.assertFalse(requester.is_guest)

# add an is_guest caveat
mac = pymacaroons.Macaroon.deserialize(token)
mac.add_first_party_caveat("guest = true")
guest_tok = mac.serialize()

# the token should *not* work now
request = Mock(args={})
request.args[b"access_token"] = [guest_tok.encode("ascii")]
request.requestHeaders.getRawHeaders = mock_getRawHeaders()

cm = self.get_failure(
self.auth.get_user_by_req(request, allow_guest=True),
InvalidClientCredentialsError,
)

self.assertEqual(401, cm.value.code)
self.assertEqual("Guest access token used for regular user", cm.value.msg)

self.store.get_user_by_id.assert_called_with(USER_ID)

def test_blocking_mau(self):
self.auth_blocking._limit_usage_by_mau = False
self.auth_blocking._max_mau_value = 50
Expand Down
43 changes: 23 additions & 20 deletions tests/handlers/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,17 @@
import pymacaroons

from synapse.api.errors import AuthError, ResourceLimitError
from synapse.rest import admin

from tests import unittest
from tests.test_utils import make_awaitable


class AuthTestCase(unittest.HomeserverTestCase):
servlets = [
admin.register_servlets,
]

def prepare(self, reactor, clock, hs):
self.auth_handler = hs.get_auth_handler()
self.macaroon_generator = hs.get_macaroon_generator()
Expand All @@ -35,16 +40,10 @@ def prepare(self, reactor, clock, hs):
self.small_number_of_users = 1
self.large_number_of_users = 100

def test_token_is_a_macaroon(self):
token = self.macaroon_generator.generate_access_token("some_user")
# Check that we can parse the thing with pymacaroons
macaroon = pymacaroons.Macaroon.deserialize(token)
# The most basic of sanity checks
if "some_user" not in macaroon.inspect():
self.fail("some_user was not in %s" % macaroon.inspect())
self.user1 = self.register_user("a_user", "pass")

def test_macaroon_caveats(self):
token = self.macaroon_generator.generate_access_token("a_user")
token = self.macaroon_generator.generate_guest_access_token("a_user")
macaroon = pymacaroons.Macaroon.deserialize(token)

def verify_gen(caveat):
Expand All @@ -59,19 +58,23 @@ def verify_type(caveat):
def verify_nonce(caveat):
return caveat.startswith("nonce =")

def verify_guest(caveat):
return caveat == "guest = true"

v = pymacaroons.Verifier()
v.satisfy_general(verify_gen)
v.satisfy_general(verify_user)
v.satisfy_general(verify_type)
v.satisfy_general(verify_nonce)
v.satisfy_general(verify_guest)
v.verify(macaroon, self.hs.config.macaroon_secret_key)

def test_short_term_login_token_gives_user_id(self):
token = self.macaroon_generator.generate_short_term_login_token(
"a_user", "", 5000
self.user1, "", 5000
)
res = self.get_success(self.auth_handler.validate_short_term_login_token(token))
self.assertEqual("a_user", res.user_id)
self.assertEqual(self.user1, res.user_id)
self.assertEqual("", res.auth_provider_id)

# when we advance the clock, the token should be rejected
Expand All @@ -83,22 +86,22 @@ def test_short_term_login_token_gives_user_id(self):

def test_short_term_login_token_gives_auth_provider(self):
token = self.macaroon_generator.generate_short_term_login_token(
"a_user", auth_provider_id="my_idp"
self.user1, auth_provider_id="my_idp"
)
res = self.get_success(self.auth_handler.validate_short_term_login_token(token))
self.assertEqual("a_user", res.user_id)
self.assertEqual(self.user1, res.user_id)
self.assertEqual("my_idp", res.auth_provider_id)

def test_short_term_login_token_cannot_replace_user_id(self):
token = self.macaroon_generator.generate_short_term_login_token(
"a_user", "", 5000
self.user1, "", 5000
)
macaroon = pymacaroons.Macaroon.deserialize(token)

res = self.get_success(
self.auth_handler.validate_short_term_login_token(macaroon.serialize())
)
self.assertEqual("a_user", res.user_id)
self.assertEqual(self.user1, res.user_id)

# add another "user_id" caveat, which might allow us to override the
# user_id.
Expand All @@ -114,7 +117,7 @@ def test_mau_limits_disabled(self):
# Ensure does not throw exception
self.get_success(
self.auth_handler.get_access_token_for_user_id(
"user_a", device_id=None, valid_until_ms=None
self.user1, device_id=None, valid_until_ms=None
)
)

Expand All @@ -132,7 +135,7 @@ def test_mau_limits_exceeded_large(self):

self.get_failure(
self.auth_handler.get_access_token_for_user_id(
"user_a", device_id=None, valid_until_ms=None
self.user1, device_id=None, valid_until_ms=None
),
ResourceLimitError,
)
Expand Down Expand Up @@ -160,7 +163,7 @@ def test_mau_limits_parity(self):
# If not in monthly active cohort
self.get_failure(
self.auth_handler.get_access_token_for_user_id(
"user_a", device_id=None, valid_until_ms=None
self.user1, device_id=None, valid_until_ms=None
),
ResourceLimitError,
)
Expand All @@ -177,7 +180,7 @@ def test_mau_limits_parity(self):
)
self.get_success(
self.auth_handler.get_access_token_for_user_id(
"user_a", device_id=None, valid_until_ms=None
self.user1, device_id=None, valid_until_ms=None
)
)
self.get_success(
Expand All @@ -195,7 +198,7 @@ def test_mau_limits_not_exceeded(self):
# Ensure does not raise exception
self.get_success(
self.auth_handler.get_access_token_for_user_id(
"user_a", device_id=None, valid_until_ms=None
self.user1, device_id=None, valid_until_ms=None
)
)

Expand All @@ -210,6 +213,6 @@ def test_mau_limits_not_exceeded(self):

def _get_macaroon(self):
token = self.macaroon_generator.generate_short_term_login_token(
"user_a", "", 5000
self.user1, "", 5000
)
return pymacaroons.Macaroon.deserialize(token)
12 changes: 4 additions & 8 deletions tests/handlers/test_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,6 @@ def prepare(self, reactor, clock, hs):
self.mock_distributor = Mock()
self.mock_distributor.declare("registered_user")
self.mock_captcha_client = Mock()
self.macaroon_generator = Mock(
generate_access_token=Mock(return_value="secret")
)
self.hs.get_macaroon_generator = Mock(return_value=self.macaroon_generator)
self.handler = self.hs.get_registration_handler()
self.store = self.hs.get_datastore()
self.lots_of_users = 100
Expand All @@ -67,8 +63,8 @@ def test_user_is_created_and_logged_in_if_doesnt_exist(self):
self.get_or_create_user(requester, frank.localpart, "Frankie")
)
self.assertEquals(result_user_id, user_id)
self.assertTrue(result_token is not None)
self.assertEquals(result_token, "secret")
self.assertIsInstance(result_token, str)
self.assertGreater(len(result_token), 20)

def test_if_user_exists(self):
store = self.hs.get_datastore()
Expand Down Expand Up @@ -500,7 +496,7 @@ def check_registration_for_spam(
user_id = self.get_success(self.handler.register_user(localpart="user"))

# Get an access token.
token = self.macaroon_generator.generate_access_token(user_id)
token = "testtok"
self.get_success(
self.store.add_access_token_to_user(
user_id=user_id, token=token, device_id=None, valid_until_ms=None
Expand Down Expand Up @@ -577,7 +573,7 @@ async def get_or_create_user(

user = UserID(localpart, self.hs.hostname)
user_id = user.to_string()
token = self.macaroon_generator.generate_access_token(user_id)
token = self.hs.get_auth_handler().generate_access_token(user)

if need_register:
await self.handler.register_with_store(
Expand Down
Loading