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 @@
Switch to using opaque strings for access tokens instead of macaroons.
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
14 changes: 5 additions & 9 deletions tests/handlers/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,8 @@ 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())

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,11 +51,15 @@ 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):
Expand Down
4 changes: 2 additions & 2 deletions tests/handlers/test_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,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 +577,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
8 changes: 7 additions & 1 deletion tests/util/test_stringutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# limitations under the License.

from synapse.api.errors import SynapseError
from synapse.util.stringutils import assert_valid_client_secret
from synapse.util.stringutils import assert_valid_client_secret, base62_encode

from .. import unittest

Expand Down Expand Up @@ -45,3 +45,9 @@ def test_client_secret_regex(self):
for client_secret in bad:
with self.assertRaises(SynapseError):
assert_valid_client_secret(client_secret)

def test_base62_encode(self):
self.assertEqual("0", base62_encode(0))
self.assertEqual("10", base62_encode(62))
self.assertEqual("1c", base62_encode(100))
self.assertEqual("001c", base62_encode(100, minwidth=4))