From 17b819fb85ea73efa78148738c5351c020bd7195 Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Fri, 30 May 2025 14:21:57 +0200 Subject: [PATCH] feat: explicit soft logout Allows invalidating all access tokens and refresh tokens for a specific session by setting "com.famedly.soft_logout" to "true" on the logout endpoint. This only updates the expiry timestamps to be in the past. Otherwise the tokens will get deleted, which forces a real logout. Fixes https://github.com/famedly/product-management/issues/3202 --- synapse/handlers/auth.py | 36 ++++++++ synapse/rest/client/logout.py | 10 ++- .../storage/databases/main/registration.py | 43 ++++++++++ tests/rest/client/test_auth.py | 83 +++++++++++++++++++ 4 files changed, 170 insertions(+), 2 deletions(-) diff --git a/synapse/handlers/auth.py b/synapse/handlers/auth.py index e96922c08d..5e2d700646 100644 --- a/synapse/handlers/auth.py +++ b/synapse/handlers/auth.py @@ -1485,6 +1485,42 @@ async def consume_login_token(self, login_token: str) -> LoginTokenLookupResult: raise AuthError(403, "Invalid login token", errcode=Codes.FORBIDDEN) + async def expire_access_token(self, access_token: str) -> None: + """Invalidate a single access token and associated refresh tokens, putting the user in the soft logout state. + + Args: + access_token: access token to be deleted + + """ + token = await self.store.get_user_by_access_token(access_token) + if not token: + # At this point, the token should already have been fetched once by + # the caller, so this should not happen, unless of a race condition + # between two delete requests + raise SynapseError(HTTPStatus.UNAUTHORIZED, "Unrecognised access token") + + await self.store.expire_access_token(access_token) + + if token.device_id is not None: + await self.store.expire_refresh_tokens_for_device( + user_id=token.user_id, device_id=token.device_id + ) + + # see if any modules want to know about this + await self.password_auth_provider.on_logged_out( + user_id=token.user_id, + device_id=token.device_id, + access_token=access_token, + ) + + # delete pushers associated with this access token + # XXX(quenting): This is only needed until the 'set_device_id_for_pushers' + # background update completes. + if token.token_id is not None: + await self.hs.get_pusherpool().remove_pushers_by_access_tokens( + token.user_id, (token.token_id,) + ) + async def delete_access_token(self, access_token: str) -> None: """Invalidate a single access token diff --git a/synapse/rest/client/logout.py b/synapse/rest/client/logout.py index e6b4a34d51..66334e07fd 100644 --- a/synapse/rest/client/logout.py +++ b/synapse/rest/client/logout.py @@ -24,7 +24,7 @@ from synapse.handlers.device import DeviceHandler from synapse.http.server import HttpServer -from synapse.http.servlet import RestServlet +from synapse.http.servlet import RestServlet, parse_json_object_from_request from synapse.http.site import SynapseRequest from synapse.rest.client._base import client_patterns from synapse.types import JsonDict @@ -51,7 +51,13 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]: request, allow_expired=True, allow_locked=True ) - if requester.device_id is None: + body = parse_json_object_from_request(request, allow_empty_body=True) + + if body.get("com.famedly.soft_logout", False): + access_token = self.auth.get_access_token_from_request(request) + # For soft_logout, just expire the tokens, don't delete them. + await self._auth_handler.expire_access_token(access_token) + elif requester.device_id is None: # The access token wasn't associated with a device. # Just delete the access token access_token = self.auth.get_access_token_from_request(request) diff --git a/synapse/storage/databases/main/registration.py b/synapse/storage/databases/main/registration.py index 8380930c70..2e81167f3c 100644 --- a/synapse/storage/databases/main/registration.py +++ b/synapse/storage/databases/main/registration.py @@ -1437,6 +1437,30 @@ async def update_access_token_last_validated(self, token_id: int) -> None: desc="update_access_token_last_validated", ) + async def expire_access_token(self, access_token: str) -> None: + """Updates the valid_until_ms for the access token to be expired. + + Args: + token_id: The ID of the access token to update. + Raises: + StoreError if there was a problem updating this. + """ + now = self._clock.time_msec() - 100 + + def f(txn: LoggingTransaction) -> None: + self.db_pool.simple_update_one_txn( + txn, + "access_tokens", + {"token": access_token}, + {"valid_until_ms": now}, + ) + + self._invalidate_cache_and_stream( + txn, self.get_user_by_access_token, (access_token,) + ) + + await self.db_pool.runInteraction("expire_access_token", f) + async def registration_token_is_valid(self, token: str) -> bool: """Checks if a token can be used to authenticate a registration. @@ -1917,6 +1941,25 @@ def _replace_refresh_token_txn(txn: LoggingTransaction) -> None: "replace_refresh_token", _replace_refresh_token_txn ) + async def expire_refresh_tokens_for_device( + self, user_id: str, device_id: str + ) -> None: + """ + Expires all refresh tokens for a specific user and device. + + Args: + user_id: the ID of the user owning the refresh token + device_id: the id of the device owning the refresh token + """ + + now = self._clock.time_msec() - 100 + await self.db_pool.simple_update( + "refresh_tokens", + {"user_id": user_id, "device_id": device_id}, + {"expiry_ts": now}, + "expire_refresh_tokens", + ) + async def add_login_token_to_user( self, user_id: str, diff --git a/tests/rest/client/test_auth.py b/tests/rest/client/test_auth.py index 3f7bdad962..e39049d2b5 100644 --- a/tests/rest/client/test_auth.py +++ b/tests/rest/client/test_auth.py @@ -1064,6 +1064,89 @@ def use_custom_refresh_token(refresh_token: str) -> FakeChannel: refresh_response.code, HTTPStatus.FORBIDDEN, refresh_response.result ) + def test_explicit_soft_logout(self) -> None: + """ + Doing an explicit soft logout should invalidate all tokens. + """ + + body = { + "type": "m.login.password", + "user": "test", + "password": self.user_pass, + "refresh_token": True, + } + login_response = self.make_request( + "POST", + "/_matrix/client/v3/login", + body, + ) + self.assertEqual(login_response.code, HTTPStatus.OK, login_response.result) + refresh_token = login_response.json_body["refresh_token"] + access_token = login_response.json_body["access_token"] + device_id = login_response.json_body["device_id"] + + # access token should be valid + whoami_response = self.make_request( + "GET", + "/_matrix/client/v3/account/whoami", + access_token=access_token, + ) + self.assertEqual(whoami_response.code, HTTPStatus.OK, whoami_response.result) + + # Do a soft logout + logout_response = self.make_request( + "POST", + "/_matrix/client/v3/logout", + { + "com.famedly.soft_logout": True, + }, + access_token=access_token, + ) + self.assertEqual(logout_response.code, HTTPStatus.OK, logout_response.result) + + # Assert that we can't refresh the token anymore + refresh_response = self.use_refresh_token(refresh_token) + self.assertEqual( + refresh_response.code, HTTPStatus.FORBIDDEN, refresh_response.result + ) + + # access token should be invalid + whoami_response = self.make_request( + "GET", + "/_matrix/client/v3/account/whoami", + access_token=access_token, + ) + self.assertEqual( + whoami_response.code, HTTPStatus.UNAUTHORIZED, whoami_response.result + ) + self.assertEqual( + whoami_response.json_body["soft_logout"], True, whoami_response.result + ) + + # Log back into the existing session + body = { + "type": "m.login.password", + "user": "test", + "password": self.user_pass, + "refresh_token": True, + "device_id": device_id, + } + login_response = self.make_request( + "POST", + "/_matrix/client/v3/login", + body, + ) + self.assertEqual(login_response.code, HTTPStatus.OK, login_response.result) + access_token = login_response.json_body["access_token"] + + # access token should be valid again + whoami_response = self.make_request( + "GET", + "/_matrix/client/v3/account/whoami", + access_token=access_token, + ) + self.assertEqual(whoami_response.code, HTTPStatus.OK, whoami_response.result) + @override_config( { "refreshable_access_token_lifetime": "2m",