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

@ -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,22 +478,33 @@ 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
)
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,
{
"access_token": access_token,
"refresh_token": refresh_token,
"expires_in_ms": expires_in_ms,
},
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
)
response: Dict[str, Union[str, int]] = {
"access_token": access_token,
"refresh_token": refresh_token,
}
# 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):
PATTERNS = list(client_patterns("/login/(cas|sso)/redirect$", v1=True)) + [

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;