Support expiry of refresh tokens and expiry of the overall session when refresh tokens are in use. (#11425)

This commit is contained in:
reivilibre 2021-11-26 14:27:14 +00:00 committed by GitHub
parent e2c300e7e4
commit 1d8b80b334
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 338 additions and 54 deletions

View File

@ -0,0 +1 @@
Support expiry of refresh tokens and expiry of the overall session when refresh tokens are in use.

View File

@ -113,14 +113,11 @@ class RegistrationConfig(Config):
self.session_lifetime = session_lifetime
# The `refreshable_access_token_lifetime` applies for tokens that can be renewed
# using a refresh token, as per MSC2918. If it is `None`, the refresh
# token mechanism is disabled.
#
# Since it is incompatible with the `session_lifetime` mechanism, it is set to
# `None` by default if a `session_lifetime` is set.
# using a refresh token, as per MSC2918.
# If it is `None`, the refresh token mechanism is disabled.
refreshable_access_token_lifetime = config.get(
"refreshable_access_token_lifetime",
"5m" if session_lifetime is None else None,
"5m",
)
if refreshable_access_token_lifetime is not None:
refreshable_access_token_lifetime = self.parse_duration(
@ -128,17 +125,10 @@ class RegistrationConfig(Config):
)
self.refreshable_access_token_lifetime = refreshable_access_token_lifetime
if (
session_lifetime is not None
and refreshable_access_token_lifetime is not None
):
raise ConfigError(
"The refresh token mechanism is incompatible with the "
"`session_lifetime` option. Consider disabling the "
"`session_lifetime` option or disabling the refresh token "
"mechanism by removing the `refreshable_access_token_lifetime` "
"option."
)
refresh_token_lifetime = config.get("refresh_token_lifetime")
if refresh_token_lifetime is not None:
refresh_token_lifetime = self.parse_duration(refresh_token_lifetime)
self.refresh_token_lifetime = refresh_token_lifetime
# The fallback template used for authenticating using a registration token
self.registration_token_template = self.read_template("registration_token.html")

View File

@ -18,6 +18,7 @@ import time
import unicodedata
import urllib.parse
from binascii import crc32
from http import HTTPStatus
from typing import (
TYPE_CHECKING,
Any,
@ -756,53 +757,109 @@ class AuthHandler:
async def refresh_token(
self,
refresh_token: str,
valid_until_ms: Optional[int],
) -> Tuple[str, str]:
access_token_valid_until_ms: Optional[int],
refresh_token_valid_until_ms: Optional[int],
) -> Tuple[str, str, Optional[int]]:
"""
Consumes a refresh token and generate both a new access token and a new refresh token from it.
The consumed refresh token is considered invalid after the first use of the new access token or the new refresh token.
The lifetime of both the access token and refresh token will be capped so that they
do not exceed the session's ultimate expiry time, if applicable.
Args:
refresh_token: The token to consume.
valid_until_ms: The expiration timestamp of the new access token.
access_token_valid_until_ms: The expiration timestamp of the new access token.
None if the access token does not expire.
refresh_token_valid_until_ms: The expiration timestamp of the new refresh token.
None if the refresh token does not expire.
Returns:
A tuple containing the new access token and refresh token
A tuple containing:
- the new access token
- the new refresh token
- the actual expiry time of the access token, which may be earlier than
`access_token_valid_until_ms`.
"""
# Verify the token signature first before looking up the token
if not self._verify_refresh_token(refresh_token):
raise SynapseError(401, "invalid refresh token", Codes.UNKNOWN_TOKEN)
raise SynapseError(
HTTPStatus.UNAUTHORIZED, "invalid refresh token", Codes.UNKNOWN_TOKEN
)
existing_token = await self.store.lookup_refresh_token(refresh_token)
if existing_token is None:
raise SynapseError(401, "refresh token does not exist", Codes.UNKNOWN_TOKEN)
raise SynapseError(
HTTPStatus.UNAUTHORIZED,
"refresh token does not exist",
Codes.UNKNOWN_TOKEN,
)
if (
existing_token.has_next_access_token_been_used
or existing_token.has_next_refresh_token_been_refreshed
):
raise SynapseError(
403, "refresh token isn't valid anymore", Codes.FORBIDDEN
HTTPStatus.FORBIDDEN,
"refresh token isn't valid anymore",
Codes.FORBIDDEN,
)
now_ms = self._clock.time_msec()
if existing_token.expiry_ts is not None and existing_token.expiry_ts < now_ms:
raise SynapseError(
HTTPStatus.FORBIDDEN,
"The supplied refresh token has expired",
Codes.FORBIDDEN,
)
if existing_token.ultimate_session_expiry_ts is not None:
# This session has a bounded lifetime, even across refreshes.
if access_token_valid_until_ms is not None:
access_token_valid_until_ms = min(
access_token_valid_until_ms,
existing_token.ultimate_session_expiry_ts,
)
else:
access_token_valid_until_ms = existing_token.ultimate_session_expiry_ts
if refresh_token_valid_until_ms is not None:
refresh_token_valid_until_ms = min(
refresh_token_valid_until_ms,
existing_token.ultimate_session_expiry_ts,
)
else:
refresh_token_valid_until_ms = existing_token.ultimate_session_expiry_ts
if existing_token.ultimate_session_expiry_ts < now_ms:
raise SynapseError(
HTTPStatus.FORBIDDEN,
"The session has expired and can no longer be refreshed",
Codes.FORBIDDEN,
)
(
new_refresh_token,
new_refresh_token_id,
) = await self.create_refresh_token_for_user_id(
user_id=existing_token.user_id, device_id=existing_token.device_id
user_id=existing_token.user_id,
device_id=existing_token.device_id,
expiry_ts=refresh_token_valid_until_ms,
ultimate_session_expiry_ts=existing_token.ultimate_session_expiry_ts,
)
access_token = await self.create_access_token_for_user_id(
user_id=existing_token.user_id,
device_id=existing_token.device_id,
valid_until_ms=valid_until_ms,
valid_until_ms=access_token_valid_until_ms,
refresh_token_id=new_refresh_token_id,
)
await self.store.replace_refresh_token(
existing_token.token_id, new_refresh_token_id
)
return access_token, new_refresh_token
return access_token, new_refresh_token, access_token_valid_until_ms
def _verify_refresh_token(self, token: str) -> bool:
"""
@ -836,6 +893,8 @@ class AuthHandler:
self,
user_id: str,
device_id: str,
expiry_ts: Optional[int],
ultimate_session_expiry_ts: Optional[int],
) -> Tuple[str, int]:
"""
Creates a new refresh token for the user with the given user ID.
@ -843,6 +902,13 @@ class AuthHandler:
Args:
user_id: canonical user ID
device_id: the device ID to associate with the token.
expiry_ts (milliseconds since the epoch): Time after which the
refresh token cannot be used.
If None, the refresh token never expires until it has been used.
ultimate_session_expiry_ts (milliseconds since the epoch):
Time at which the session will end and can not be extended any
further.
If None, the session can be refreshed indefinitely.
Returns:
The newly created refresh token and its ID in the database
@ -852,6 +918,8 @@ class AuthHandler:
user_id=user_id,
token=refresh_token,
device_id=device_id,
expiry_ts=expiry_ts,
ultimate_session_expiry_ts=ultimate_session_expiry_ts,
)
return refresh_token, refresh_token_id

View File

@ -119,6 +119,7 @@ class RegistrationHandler:
self.refreshable_access_token_lifetime = (
hs.config.registration.refreshable_access_token_lifetime
)
self.refresh_token_lifetime = hs.config.registration.refresh_token_lifetime
init_counters_for_auth_provider("")
@ -793,13 +794,13 @@ class RegistrationHandler:
class and RegisterDeviceReplicationServlet.
"""
assert not self.hs.config.worker.worker_app
valid_until_ms = None
access_token_expiry = None
if self.session_lifetime is not None:
if is_guest:
raise Exception(
"session_lifetime is not currently implemented for guest access"
)
valid_until_ms = self.clock.time_msec() + self.session_lifetime
access_token_expiry = self.clock.time_msec() + self.session_lifetime
refresh_token = None
refresh_token_id = None
@ -808,25 +809,52 @@ class RegistrationHandler:
user_id, device_id, initial_display_name
)
if is_guest:
assert valid_until_ms is None
assert access_token_expiry is None
access_token = self.macaroon_gen.generate_guest_access_token(user_id)
else:
if should_issue_refresh_token:
now_ms = self.clock.time_msec()
# Set the expiry time of the refreshable access token
access_token_expiry = now_ms + self.refreshable_access_token_lifetime
# Set the refresh token expiry time (if configured)
refresh_token_expiry = None
if self.refresh_token_lifetime is not None:
refresh_token_expiry = now_ms + self.refresh_token_lifetime
# Set an ultimate session expiry time (if configured)
ultimate_session_expiry_ts = None
if self.session_lifetime is not None:
ultimate_session_expiry_ts = now_ms + self.session_lifetime
# Also ensure that the issued tokens don't outlive the
# session.
# (It would be weird to configure a homeserver with a shorter
# session lifetime than token lifetime, but may as well handle
# it.)
access_token_expiry = min(
access_token_expiry, ultimate_session_expiry_ts
)
if refresh_token_expiry is not None:
refresh_token_expiry = min(
refresh_token_expiry, ultimate_session_expiry_ts
)
(
refresh_token,
refresh_token_id,
) = await self._auth_handler.create_refresh_token_for_user_id(
user_id,
device_id=registered_device_id,
)
valid_until_ms = (
self.clock.time_msec() + self.refreshable_access_token_lifetime
expiry_ts=refresh_token_expiry,
ultimate_session_expiry_ts=ultimate_session_expiry_ts,
)
access_token = await self._auth_handler.create_access_token_for_user_id(
user_id,
device_id=registered_device_id,
valid_until_ms=valid_until_ms,
valid_until_ms=access_token_expiry,
is_appservice_ghost=is_appservice_ghost,
refresh_token_id=refresh_token_id,
)
@ -834,7 +862,7 @@ class RegistrationHandler:
return {
"device_id": registered_device_id,
"access_token": access_token,
"valid_until_ms": valid_until_ms,
"valid_until_ms": access_token_expiry,
"refresh_token": refresh_token,
}

View File

@ -14,7 +14,17 @@
import logging
import re
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional, Tuple
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
Dict,
List,
Optional,
Tuple,
Union,
)
from typing_extensions import TypedDict
@ -458,6 +468,7 @@ class RefreshTokenServlet(RestServlet):
self.refreshable_access_token_lifetime = (
hs.config.registration.refreshable_access_token_lifetime
)
self.refresh_token_lifetime = hs.config.registration.refresh_token_lifetime
async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
refresh_submission = parse_json_object_from_request(request)
@ -467,21 +478,32 @@ class RefreshTokenServlet(RestServlet):
if not isinstance(token, str):
raise SynapseError(400, "Invalid param: refresh_token", Codes.INVALID_PARAM)
valid_until_ms = (
self._clock.time_msec() + self.refreshable_access_token_lifetime
now = self._clock.time_msec()
access_valid_until_ms = None
if self.refreshable_access_token_lifetime is not None:
access_valid_until_ms = now + self.refreshable_access_token_lifetime
refresh_valid_until_ms = None
if self.refresh_token_lifetime is not None:
refresh_valid_until_ms = now + self.refresh_token_lifetime
(
access_token,
refresh_token,
actual_access_token_expiry,
) = await self._auth_handler.refresh_token(
token, access_valid_until_ms, refresh_valid_until_ms
)
access_token, refresh_token = await self._auth_handler.refresh_token(
token, valid_until_ms
)
expires_in_ms = valid_until_ms - self._clock.time_msec()
return (
200,
{
response: Dict[str, Union[str, int]] = {
"access_token": access_token,
"refresh_token": refresh_token,
"expires_in_ms": expires_in_ms,
},
)
}
# expires_in_ms is only present if the token expires
if actual_access_token_expiry is not None:
response["expires_in_ms"] = actual_access_token_expiry - now
return 200, response
class SsoRedirectServlet(RestServlet):

View File

@ -106,6 +106,15 @@ class RefreshTokenLookupResult:
has_next_access_token_been_used: bool
"""True if the next access token was already used at least once."""
expiry_ts: Optional[int]
"""The time at which the refresh token expires and can not be used.
If None, the refresh token doesn't expire."""
ultimate_session_expiry_ts: Optional[int]
"""The time at which the session comes to an end and can no longer be
refreshed.
If None, the session can be refreshed indefinitely."""
class RegistrationWorkerStore(CacheInvalidationWorkerStore):
def __init__(
@ -1626,8 +1635,10 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
rt.user_id,
rt.device_id,
rt.next_token_id,
(nrt.next_token_id IS NOT NULL) has_next_refresh_token_been_refreshed,
at.used has_next_access_token_been_used
(nrt.next_token_id IS NOT NULL) AS has_next_refresh_token_been_refreshed,
at.used AS has_next_access_token_been_used,
rt.expiry_ts,
rt.ultimate_session_expiry_ts
FROM refresh_tokens rt
LEFT JOIN refresh_tokens nrt ON rt.next_token_id = nrt.id
LEFT JOIN access_tokens at ON at.refresh_token_id = nrt.id
@ -1648,6 +1659,8 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
has_next_refresh_token_been_refreshed=row[4],
# This column is nullable, ensure it's a boolean
has_next_access_token_been_used=(row[5] or False),
expiry_ts=row[6],
ultimate_session_expiry_ts=row[7],
)
return await self.db_pool.runInteraction(
@ -1915,6 +1928,8 @@ class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore):
user_id: str,
token: str,
device_id: Optional[str],
expiry_ts: Optional[int],
ultimate_session_expiry_ts: Optional[int],
) -> int:
"""Adds a refresh token for the given user.
@ -1922,6 +1937,13 @@ class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore):
user_id: The user ID.
token: The new access token to add.
device_id: ID of the device to associate with the refresh token.
expiry_ts (milliseconds since the epoch): Time after which the
refresh token cannot be used.
If None, the refresh token never expires until it has been used.
ultimate_session_expiry_ts (milliseconds since the epoch):
Time at which the session will end and can not be extended any
further.
If None, the session can be refreshed indefinitely.
Raises:
StoreError if there was a problem adding this.
Returns:
@ -1937,6 +1959,8 @@ class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore):
"device_id": device_id,
"token": token,
"next_token_id": None,
"expiry_ts": expiry_ts,
"ultimate_session_expiry_ts": ultimate_session_expiry_ts,
},
desc="add_refresh_token_to_user",
)

View File

@ -0,0 +1,28 @@
/* Copyright 2021 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.
*/
ALTER TABLE refresh_tokens
-- We add an expiry_ts column (in milliseconds since the Epoch) to refresh tokens.
-- They may not be used after they have expired.
-- If null, then the refresh token's lifetime is unlimited.
ADD COLUMN expiry_ts BIGINT DEFAULT NULL;
ALTER TABLE refresh_tokens
-- We also add an ultimate session expiry time (in milliseconds since the Epoch).
-- No matter how much the access and refresh tokens are refreshed, they cannot
-- be extended past this time.
-- If null, then the session length is unlimited.
ADD COLUMN ultimate_session_expiry_ts BIGINT DEFAULT NULL;

View File

@ -12,6 +12,7 @@
# 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 http import HTTPStatus
from typing import Optional, Union
from twisted.internet.defer import succeed
@ -513,6 +514,16 @@ class RefreshAuthTests(unittest.HomeserverTestCase):
self.user_pass = "pass"
self.user = self.register_user("test", self.user_pass)
def use_refresh_token(self, refresh_token: str) -> FakeChannel:
"""
Helper that makes a request to use a refresh token.
"""
return self.make_request(
"POST",
"/_matrix/client/unstable/org.matrix.msc2918.refresh_token/refresh",
{"refresh_token": refresh_token},
)
def test_login_issue_refresh_token(self):
"""
A login response should include a refresh_token only if asked.
@ -599,7 +610,7 @@ class RefreshAuthTests(unittest.HomeserverTestCase):
)
@override_config({"refreshable_access_token_lifetime": "1m"})
def test_refresh_token_expiration(self):
def test_refreshable_access_token_expiration(self):
"""
The access token should have some time as specified in the config.
"""
@ -623,6 +634,118 @@ class RefreshAuthTests(unittest.HomeserverTestCase):
self.assertApproximates(
refresh_response.json_body["expires_in_ms"], 60 * 1000, 100
)
access_token = refresh_response.json_body["access_token"]
# Advance 59 seconds in the future (just shy of 1 minute, the time of expiry)
self.reactor.advance(59.0)
# Check that our token is valid
self.assertEqual(
self.make_request(
"GET", "/_matrix/client/v3/account/whoami", access_token=access_token
).code,
HTTPStatus.OK,
)
# Advance 2 more seconds (just past the time of expiry)
self.reactor.advance(2.0)
# Check that our token is invalid
self.assertEqual(
self.make_request(
"GET", "/_matrix/client/v3/account/whoami", access_token=access_token
).code,
HTTPStatus.UNAUTHORIZED,
)
@override_config(
{"refreshable_access_token_lifetime": "1m", "refresh_token_lifetime": "2m"}
)
def test_refresh_token_expiry(self):
"""
The refresh token can be configured to have a limited lifetime.
When that lifetime has ended, the refresh token can no longer be used to
refresh the session.
"""
body = {"type": "m.login.password", "user": "test", "password": self.user_pass}
login_response = self.make_request(
"POST",
"/_matrix/client/r0/login?org.matrix.msc2918.refresh_token=true",
body,
)
self.assertEqual(login_response.code, HTTPStatus.OK, login_response.result)
refresh_token1 = login_response.json_body["refresh_token"]
# Advance 119 seconds in the future (just shy of 2 minutes)
self.reactor.advance(119.0)
# Refresh our session. The refresh token should still JUST be valid right now.
# By doing so, we get a new access token and a new refresh token.
refresh_response = self.use_refresh_token(refresh_token1)
self.assertEqual(refresh_response.code, HTTPStatus.OK, refresh_response.result)
self.assertIn(
"refresh_token",
refresh_response.json_body,
"No new refresh token returned after refresh.",
)
refresh_token2 = refresh_response.json_body["refresh_token"]
# Advance 121 seconds in the future (just a bit more than 2 minutes)
self.reactor.advance(121.0)
# Try to refresh our session, but instead notice that the refresh token is
# not valid (it just expired).
refresh_response = self.use_refresh_token(refresh_token2)
self.assertEqual(
refresh_response.code, HTTPStatus.FORBIDDEN, refresh_response.result
)
@override_config(
{
"refreshable_access_token_lifetime": "2m",
"refresh_token_lifetime": "2m",
"session_lifetime": "3m",
}
)
def test_ultimate_session_expiry(self):
"""
The session can be configured to have an ultimate, limited lifetime.
"""
body = {"type": "m.login.password", "user": "test", "password": self.user_pass}
login_response = self.make_request(
"POST",
"/_matrix/client/r0/login?org.matrix.msc2918.refresh_token=true",
body,
)
self.assertEqual(login_response.code, 200, login_response.result)
refresh_token = login_response.json_body["refresh_token"]
# Advance shy of 2 minutes into the future
self.reactor.advance(119.0)
# Refresh our session. The refresh token should still be valid right now.
refresh_response = self.use_refresh_token(refresh_token)
self.assertEqual(refresh_response.code, 200, refresh_response.result)
self.assertIn(
"refresh_token",
refresh_response.json_body,
"No new refresh token returned after refresh.",
)
# Notice that our access token lifetime has been diminished to match the
# session lifetime.
# 3 minutes - 119 seconds = 61 seconds.
self.assertEqual(refresh_response.json_body["expires_in_ms"], 61_000)
refresh_token = refresh_response.json_body["refresh_token"]
# Advance 61 seconds into the future. Our session should have expired
# now, because we've had our 3 minutes.
self.reactor.advance(61.0)
# Try to issue a new, refreshed, access token.
# This should fail because the refresh token's lifetime has also been
# diminished as our session expired.
refresh_response = self.use_refresh_token(refresh_token)
self.assertEqual(refresh_response.code, 403, refresh_response.result)
def test_refresh_token_invalidation(self):
"""Refresh tokens are invalidated after first use of the next token.