Implement stable support for MSC3882 to allow an existing device/session to generate a login token for use on a new device/session (#15388)

Implements stable support for MSC3882; this involves updating Synapse's support to
match the MSC / the spec says.

Continue to support the unstable version to allow clients to transition.
This commit is contained in:
Hugh Nimmo-Smith 2023-06-01 13:52:51 +01:00 committed by GitHub
parent 0b5f64ff09
commit d1693f0362
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 225 additions and 75 deletions

View File

@ -0,0 +1 @@
Stable support for [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882) to allow an existing device/session to generate a login token for use on a new device/session.

View File

@ -2570,7 +2570,50 @@ Example configuration:
```yaml ```yaml
nonrefreshable_access_token_lifetime: 24h nonrefreshable_access_token_lifetime: 24h
``` ```
---
### `ui_auth`
The amount of time to allow a user-interactive authentication session to be active.
This defaults to 0, meaning the user is queried for their credentials
before every action, but this can be overridden to allow a single
validation to be re-used. This weakens the protections afforded by
the user-interactive authentication process, by allowing for multiple
(and potentially different) operations to use the same validation session.
This is ignored for potentially "dangerous" operations (including
deactivating an account, modifying an account password, adding a 3PID,
and minting additional login tokens).
Use the `session_timeout` sub-option here to change the time allowed for credential validation.
Example configuration:
```yaml
ui_auth:
session_timeout: "15s"
```
---
### `login_via_existing_session`
Matrix supports the ability of an existing session to mint a login token for
another client.
Synapse disables this by default as it has security ramifications -- a malicious
client could use the mechanism to spawn more than one session.
The duration of time the generated token is valid for can be configured with the
`token_timeout` sub-option.
User-interactive authentication is required when this is enabled unless the
`require_ui_auth` sub-option is set to `False`.
Example configuration:
```yaml
login_via_existing_session:
enabled: true
require_ui_auth: false
token_timeout: "5m"
```
--- ---
## Metrics ## Metrics
Config options related to metrics. Config options related to metrics.
@ -3415,28 +3458,6 @@ password_config:
require_uppercase: true require_uppercase: true
``` ```
--- ---
### `ui_auth`
The amount of time to allow a user-interactive authentication session to be active.
This defaults to 0, meaning the user is queried for their credentials
before every action, but this can be overridden to allow a single
validation to be re-used. This weakens the protections afforded by
the user-interactive authentication process, by allowing for multiple
(and potentially different) operations to use the same validation session.
This is ignored for potentially "dangerous" operations (including
deactivating an account, modifying an account password, and
adding a 3PID).
Use the `session_timeout` sub-option here to change the time allowed for credential validation.
Example configuration:
```yaml
ui_auth:
session_timeout: "15s"
```
---
## Push ## Push
Configuration settings related to push notifications Configuration settings related to push notifications

View File

@ -60,3 +60,13 @@ class AuthConfig(Config):
self.ui_auth_session_timeout = self.parse_duration( self.ui_auth_session_timeout = self.parse_duration(
ui_auth.get("session_timeout", 0) ui_auth.get("session_timeout", 0)
) )
# Logging in with an existing session.
login_via_existing = config.get("login_via_existing_session", {})
self.login_via_existing_enabled = login_via_existing.get("enabled", False)
self.login_via_existing_require_ui_auth = login_via_existing.get(
"require_ui_auth", True
)
self.login_via_existing_token_timeout = self.parse_duration(
login_via_existing.get("token_timeout", "5m")
)

View File

@ -192,10 +192,10 @@ class MSC3861:
("captcha", "enable_registration_captcha"), ("captcha", "enable_registration_captcha"),
) )
if root.experimental.msc3882_enabled: if root.auth.login_via_existing_enabled:
raise ConfigError( raise ConfigError(
"MSC3882 cannot be enabled when OAuth delegation is enabled", "Login via existing session cannot be enabled when OAuth delegation is enabled",
("experimental_features", "msc3882_enabled"), ("login_via_existing_session", "enabled"),
) )
if root.registration.refresh_token_lifetime: if root.registration.refresh_token_lifetime:
@ -319,13 +319,6 @@ class ExperimentalConfig(Config):
# MSC3881: Remotely toggle push notifications for another client # MSC3881: Remotely toggle push notifications for another client
self.msc3881_enabled: bool = experimental.get("msc3881_enabled", False) self.msc3881_enabled: bool = experimental.get("msc3881_enabled", False)
# MSC3882: Allow an existing session to sign in a new session
self.msc3882_enabled: bool = experimental.get("msc3882_enabled", False)
self.msc3882_ui_auth: bool = experimental.get("msc3882_ui_auth", True)
self.msc3882_token_timeout = self.parse_duration(
experimental.get("msc3882_token_timeout", "5m")
)
# MSC3874: Filtering /messages with rel_types / not_rel_types. # MSC3874: Filtering /messages with rel_types / not_rel_types.
self.msc3874_enabled: bool = experimental.get("msc3874_enabled", False) self.msc3874_enabled: bool = experimental.get("msc3874_enabled", False)

View File

@ -65,6 +65,9 @@ class CapabilitiesRestServlet(RestServlet):
"m.3pid_changes": { "m.3pid_changes": {
"enabled": self.config.registration.enable_3pid_changes "enabled": self.config.registration.enable_3pid_changes
}, },
"m.get_login_token": {
"enabled": self.config.auth.login_via_existing_enabled,
},
} }
} }

View File

@ -104,6 +104,9 @@ class LoginRestServlet(RestServlet):
and hs.config.experimental.msc3866.require_approval_for_new_accounts and hs.config.experimental.msc3866.require_approval_for_new_accounts
) )
# Whether get login token is enabled.
self._get_login_token_enabled = hs.config.auth.login_via_existing_enabled
self.auth = hs.get_auth() self.auth = hs.get_auth()
self.clock = hs.get_clock() self.clock = hs.get_clock()
@ -142,6 +145,9 @@ class LoginRestServlet(RestServlet):
# to SSO. # to SSO.
flows.append({"type": LoginRestServlet.CAS_TYPE}) flows.append({"type": LoginRestServlet.CAS_TYPE})
# The login token flow requires m.login.token to be advertised.
support_login_token_flow = self._get_login_token_enabled
if self.cas_enabled or self.saml2_enabled or self.oidc_enabled: if self.cas_enabled or self.saml2_enabled or self.oidc_enabled:
flows.append( flows.append(
{ {
@ -153,14 +159,23 @@ class LoginRestServlet(RestServlet):
} }
) )
# SSO requires a login token to be generated, so we need to advertise that flow
support_login_token_flow = True
# While it's valid for us to advertise this login type generally, # While it's valid for us to advertise this login type generally,
# synapse currently only gives out these tokens as part of the # synapse currently only gives out these tokens as part of the
# SSO login flow. # SSO login flow or as part of login via an existing session.
#
# Generally we don't want to advertise login flows that clients # Generally we don't want to advertise login flows that clients
# don't know how to implement, since they (currently) will always # don't know how to implement, since they (currently) will always
# fall back to the fallback API if they don't understand one of the # fall back to the fallback API if they don't understand one of the
# login flow types returned. # login flow types returned.
flows.append({"type": LoginRestServlet.TOKEN_TYPE}) if support_login_token_flow:
tokenTypeFlow: Dict[str, Any] = {"type": LoginRestServlet.TOKEN_TYPE}
# If the login token flow is enabled advertise the get_login_token flag.
if self._get_login_token_enabled:
tokenTypeFlow["get_login_token"] = True
flows.append(tokenTypeFlow)
flows.extend({"type": t} for t in self.auth_handler.get_supported_login_types()) flows.extend({"type": t} for t in self.auth_handler.get_supported_login_types())

View File

@ -15,6 +15,7 @@
import logging import logging
from typing import TYPE_CHECKING, Tuple from typing import TYPE_CHECKING, Tuple
from synapse.api.ratelimiting import Ratelimiter
from synapse.http.server import HttpServer from synapse.http.server import HttpServer
from synapse.http.servlet import RestServlet, parse_json_object_from_request from synapse.http.servlet import RestServlet, parse_json_object_from_request
from synapse.http.site import SynapseRequest from synapse.http.site import SynapseRequest
@ -33,7 +34,7 @@ class LoginTokenRequestServlet(RestServlet):
Request: Request:
POST /login/token HTTP/1.1 POST /login/get_token HTTP/1.1
Content-Type: application/json Content-Type: application/json
{} {}
@ -43,30 +44,45 @@ class LoginTokenRequestServlet(RestServlet):
HTTP/1.1 200 OK HTTP/1.1 200 OK
{ {
"login_token": "ABDEFGH", "login_token": "ABDEFGH",
"expires_in": 3600, "expires_in_ms": 3600000,
} }
""" """
PATTERNS = client_patterns( PATTERNS = [
*client_patterns(
"/login/get_token$", releases=["v1"], v1=False, unstable=False
),
# TODO: this is no longer needed once unstable MSC3882 does not need to be supported:
*client_patterns(
"/org.matrix.msc3882/login/token$", releases=[], v1=False, unstable=True "/org.matrix.msc3882/login/token$", releases=[], v1=False, unstable=True
) ),
]
def __init__(self, hs: "HomeServer"): def __init__(self, hs: "HomeServer"):
super().__init__() super().__init__()
self.auth = hs.get_auth() self.auth = hs.get_auth()
self.store = hs.get_datastores().main self._main_store = hs.get_datastores().main
self.clock = hs.get_clock()
self.server_name = hs.config.server.server_name
self.auth_handler = hs.get_auth_handler() self.auth_handler = hs.get_auth_handler()
self.token_timeout = hs.config.experimental.msc3882_token_timeout self.token_timeout = hs.config.auth.login_via_existing_token_timeout
self.ui_auth = hs.config.experimental.msc3882_ui_auth self._require_ui_auth = hs.config.auth.login_via_existing_require_ui_auth
# Ratelimit aggressively to a maxmimum of 1 request per minute.
#
# This endpoint can be used to spawn additional sessions and could be
# abused by a malicious client to create many sessions.
self._ratelimiter = Ratelimiter(
store=self._main_store,
clock=hs.get_clock(),
rate_hz=1 / 60,
burst_count=1,
)
@interactive_auth_handler @interactive_auth_handler
async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]: async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
requester = await self.auth.get_user_by_req(request) requester = await self.auth.get_user_by_req(request)
body = parse_json_object_from_request(request) body = parse_json_object_from_request(request)
if self.ui_auth: if self._require_ui_auth:
await self.auth_handler.validate_user_via_ui_auth( await self.auth_handler.validate_user_via_ui_auth(
requester, requester,
request, request,
@ -75,9 +91,12 @@ class LoginTokenRequestServlet(RestServlet):
can_skip_ui_auth=False, # Don't allow skipping of UI auth can_skip_ui_auth=False, # Don't allow skipping of UI auth
) )
# Ensure that this endpoint isn't being used too often. (Ensure this is
# done *after* UI auth.)
await self._ratelimiter.ratelimit(None, requester.user.to_string().lower())
login_token = await self.auth_handler.create_login_token_for_user_id( login_token = await self.auth_handler.create_login_token_for_user_id(
user_id=requester.user.to_string(), user_id=requester.user.to_string(),
auth_provider_id="org.matrix.msc3882.login_token_request",
duration_ms=self.token_timeout, duration_ms=self.token_timeout,
) )
@ -85,11 +104,13 @@ class LoginTokenRequestServlet(RestServlet):
200, 200,
{ {
"login_token": login_token, "login_token": login_token,
# TODO: this is no longer needed once unstable MSC3882 does not need to be supported:
"expires_in": self.token_timeout // 1000, "expires_in": self.token_timeout // 1000,
"expires_in_ms": self.token_timeout,
}, },
) )
def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
if hs.config.experimental.msc3882_enabled: if hs.config.auth.login_via_existing_enabled:
LoginTokenRequestServlet(hs).register(http_server) LoginTokenRequestServlet(hs).register(http_server)

View File

@ -113,8 +113,8 @@ class VersionsRestServlet(RestServlet):
"fi.mau.msc2815": self.config.experimental.msc2815_enabled, "fi.mau.msc2815": self.config.experimental.msc2815_enabled,
# Adds a ping endpoint for appservices to check HS->AS connection # Adds a ping endpoint for appservices to check HS->AS connection
"fi.mau.msc2659.stable": True, # TODO: remove when "v1.7" is added above "fi.mau.msc2659.stable": True, # TODO: remove when "v1.7" is added above
# Adds support for login token requests as per MSC3882 # TODO: this is no longer needed once unstable MSC3882 does not need to be supported:
"org.matrix.msc3882": self.config.experimental.msc3882_enabled, "org.matrix.msc3882": self.config.auth.login_via_existing_enabled,
# Adds support for remotely enabling/disabling pushers, as per MSC3881 # Adds support for remotely enabling/disabling pushers, as per MSC3881
"org.matrix.msc3881": self.config.experimental.msc3881_enabled, "org.matrix.msc3881": self.config.experimental.msc3881_enabled,
# Adds support for filtering /messages by event relation. # Adds support for filtering /messages by event relation.

View File

@ -228,8 +228,8 @@ class MSC3861OAuthDelegation(TestCase):
with self.assertRaises(ConfigError): with self.assertRaises(ConfigError):
self.parse_config() self.parse_config()
def test_msc3882_auth_cannot_be_enabled(self) -> None: def test_login_via_existing_session_cannot_be_enabled(self) -> None:
self.config_dict["experimental_features"]["msc3882_enabled"] = True self.config_dict["login_via_existing_session"] = {"enabled": True}
with self.assertRaises(ConfigError): with self.assertRaises(ConfigError):
self.parse_config() self.parse_config()

View File

@ -186,3 +186,31 @@ class CapabilitiesTestCase(unittest.HomeserverTestCase):
self.assertGreater(len(details["support"]), 0) self.assertGreater(len(details["support"]), 0)
for room_version in details["support"]: for room_version in details["support"]:
self.assertTrue(room_version in KNOWN_ROOM_VERSIONS, str(room_version)) self.assertTrue(room_version in KNOWN_ROOM_VERSIONS, str(room_version))
def test_get_get_token_login_fields_when_disabled(self) -> None:
"""By default login via an existing session is disabled."""
access_token = self.get_success(
self.auth_handler.create_access_token_for_user_id(
self.user, device_id=None, valid_until_ms=None
)
)
channel = self.make_request("GET", self.url, access_token=access_token)
capabilities = channel.json_body["capabilities"]
self.assertEqual(channel.code, HTTPStatus.OK)
self.assertFalse(capabilities["m.get_login_token"]["enabled"])
@override_config({"login_via_existing_session": {"enabled": True}})
def test_get_get_token_login_fields_when_enabled(self) -> None:
access_token = self.get_success(
self.auth_handler.create_access_token_for_user_id(
self.user, device_id=None, valid_until_ms=None
)
)
channel = self.make_request("GET", self.url, access_token=access_token)
capabilities = channel.json_body["capabilities"]
self.assertEqual(channel.code, HTTPStatus.OK)
self.assertTrue(capabilities["m.get_login_token"]["enabled"])

View File

@ -446,6 +446,29 @@ class LoginRestServletTestCase(unittest.HomeserverTestCase):
ApprovalNoticeMedium.NONE, channel.json_body["approval_notice_medium"] ApprovalNoticeMedium.NONE, channel.json_body["approval_notice_medium"]
) )
def test_get_login_flows_with_login_via_existing_disabled(self) -> None:
"""GET /login should return m.login.token without get_login_token"""
channel = self.make_request("GET", "/_matrix/client/r0/login")
self.assertEqual(channel.code, 200, channel.result)
flows = {flow["type"]: flow for flow in channel.json_body["flows"]}
self.assertNotIn("m.login.token", flows)
@override_config({"login_via_existing_session": {"enabled": True}})
def test_get_login_flows_with_login_via_existing_enabled(self) -> None:
"""GET /login should return m.login.token with get_login_token true"""
channel = self.make_request("GET", "/_matrix/client/r0/login")
self.assertEqual(channel.code, 200, channel.result)
self.assertCountEqual(
channel.json_body["flows"],
[
{"type": "m.login.token", "get_login_token": True},
{"type": "m.login.password"},
{"type": "m.login.application_service"},
],
)
@skip_unless(has_saml2 and HAS_OIDC, "Requires SAML2 and OIDC") @skip_unless(has_saml2 and HAS_OIDC, "Requires SAML2 and OIDC")
class MultiSSOTestCase(unittest.HomeserverTestCase): class MultiSSOTestCase(unittest.HomeserverTestCase):

View File

@ -15,14 +15,14 @@
from twisted.test.proto_helpers import MemoryReactor from twisted.test.proto_helpers import MemoryReactor
from synapse.rest import admin from synapse.rest import admin
from synapse.rest.client import login, login_token_request from synapse.rest.client import login, login_token_request, versions
from synapse.server import HomeServer from synapse.server import HomeServer
from synapse.util import Clock from synapse.util import Clock
from tests import unittest from tests import unittest
from tests.unittest import override_config from tests.unittest import override_config
endpoint = "/_matrix/client/unstable/org.matrix.msc3882/login/token" GET_TOKEN_ENDPOINT = "/_matrix/client/v1/login/get_token"
class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase): class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
@ -30,6 +30,7 @@ class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
login.register_servlets, login.register_servlets,
admin.register_servlets, admin.register_servlets,
login_token_request.register_servlets, login_token_request.register_servlets,
versions.register_servlets, # TODO: remove once unstable revision 0 support is removed
] ]
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer: def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
@ -46,26 +47,26 @@ class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
self.password = "password" self.password = "password"
def test_disabled(self) -> None: def test_disabled(self) -> None:
channel = self.make_request("POST", endpoint, {}, access_token=None) channel = self.make_request("POST", GET_TOKEN_ENDPOINT, {}, access_token=None)
self.assertEqual(channel.code, 404) self.assertEqual(channel.code, 404)
self.register_user(self.user, self.password) self.register_user(self.user, self.password)
token = self.login(self.user, self.password) token = self.login(self.user, self.password)
channel = self.make_request("POST", endpoint, {}, access_token=token) channel = self.make_request("POST", GET_TOKEN_ENDPOINT, {}, access_token=token)
self.assertEqual(channel.code, 404) self.assertEqual(channel.code, 404)
@override_config({"experimental_features": {"msc3882_enabled": True}}) @override_config({"login_via_existing_session": {"enabled": True}})
def test_require_auth(self) -> None: def test_require_auth(self) -> None:
channel = self.make_request("POST", endpoint, {}, access_token=None) channel = self.make_request("POST", GET_TOKEN_ENDPOINT, {}, access_token=None)
self.assertEqual(channel.code, 401) self.assertEqual(channel.code, 401)
@override_config({"experimental_features": {"msc3882_enabled": True}}) @override_config({"login_via_existing_session": {"enabled": True}})
def test_uia_on(self) -> None: def test_uia_on(self) -> None:
user_id = self.register_user(self.user, self.password) user_id = self.register_user(self.user, self.password)
token = self.login(self.user, self.password) token = self.login(self.user, self.password)
channel = self.make_request("POST", endpoint, {}, access_token=token) channel = self.make_request("POST", GET_TOKEN_ENDPOINT, {}, access_token=token)
self.assertEqual(channel.code, 401) self.assertEqual(channel.code, 401)
self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"]) self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
@ -80,9 +81,9 @@ class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
}, },
} }
channel = self.make_request("POST", endpoint, uia, access_token=token) channel = self.make_request("POST", GET_TOKEN_ENDPOINT, uia, access_token=token)
self.assertEqual(channel.code, 200) self.assertEqual(channel.code, 200)
self.assertEqual(channel.json_body["expires_in"], 300) self.assertEqual(channel.json_body["expires_in_ms"], 300000)
login_token = channel.json_body["login_token"] login_token = channel.json_body["login_token"]
@ -95,15 +96,15 @@ class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
self.assertEqual(channel.json_body["user_id"], user_id) self.assertEqual(channel.json_body["user_id"], user_id)
@override_config( @override_config(
{"experimental_features": {"msc3882_enabled": True, "msc3882_ui_auth": False}} {"login_via_existing_session": {"enabled": True, "require_ui_auth": False}}
) )
def test_uia_off(self) -> None: def test_uia_off(self) -> None:
user_id = self.register_user(self.user, self.password) user_id = self.register_user(self.user, self.password)
token = self.login(self.user, self.password) token = self.login(self.user, self.password)
channel = self.make_request("POST", endpoint, {}, access_token=token) channel = self.make_request("POST", GET_TOKEN_ENDPOINT, {}, access_token=token)
self.assertEqual(channel.code, 200) self.assertEqual(channel.code, 200)
self.assertEqual(channel.json_body["expires_in"], 300) self.assertEqual(channel.json_body["expires_in_ms"], 300000)
login_token = channel.json_body["login_token"] login_token = channel.json_body["login_token"]
@ -117,10 +118,10 @@ class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
@override_config( @override_config(
{ {
"experimental_features": { "login_via_existing_session": {
"msc3882_enabled": True, "enabled": True,
"msc3882_ui_auth": False, "require_ui_auth": False,
"msc3882_token_timeout": "15s", "token_timeout": "15s",
} }
} }
) )
@ -128,6 +129,40 @@ class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
self.register_user(self.user, self.password) self.register_user(self.user, self.password)
token = self.login(self.user, self.password) token = self.login(self.user, self.password)
channel = self.make_request("POST", endpoint, {}, access_token=token) channel = self.make_request("POST", GET_TOKEN_ENDPOINT, {}, access_token=token)
self.assertEqual(channel.code, 200)
self.assertEqual(channel.json_body["expires_in_ms"], 15000)
@override_config(
{
"login_via_existing_session": {
"enabled": True,
"require_ui_auth": False,
"token_timeout": "15s",
}
}
)
def test_unstable_support(self) -> None:
# TODO: remove support for unstable MSC3882 is no longer needed
# check feature is advertised in versions response:
channel = self.make_request(
"GET", "/_matrix/client/versions", {}, access_token=None
)
self.assertEqual(channel.code, 200)
self.assertEqual(
channel.json_body["unstable_features"]["org.matrix.msc3882"], True
)
self.register_user(self.user, self.password)
token = self.login(self.user, self.password)
# check feature is available via the unstable endpoint and returns an expires_in value in seconds
channel = self.make_request(
"POST",
"/_matrix/client/unstable/org.matrix.msc3882/login/token",
{},
access_token=token,
)
self.assertEqual(channel.code, 200) self.assertEqual(channel.code, 200)
self.assertEqual(channel.json_body["expires_in"], 15) self.assertEqual(channel.json_body["expires_in"], 15)