Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit d1693f0

Browse files
authored
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.
1 parent 0b5f64f commit d1693f0

File tree

12 files changed

+225
-75
lines changed

12 files changed

+225
-75
lines changed

changelog.d/15388.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

docs/usage/configuration/config_documentation.md

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2570,7 +2570,50 @@ Example configuration:
25702570
```yaml
25712571
nonrefreshable_access_token_lifetime: 24h
25722572
```
2573+
---
2574+
### `ui_auth`
2575+
2576+
The amount of time to allow a user-interactive authentication session to be active.
25732577

2578+
This defaults to 0, meaning the user is queried for their credentials
2579+
before every action, but this can be overridden to allow a single
2580+
validation to be re-used. This weakens the protections afforded by
2581+
the user-interactive authentication process, by allowing for multiple
2582+
(and potentially different) operations to use the same validation session.
2583+
2584+
This is ignored for potentially "dangerous" operations (including
2585+
deactivating an account, modifying an account password, adding a 3PID,
2586+
and minting additional login tokens).
2587+
2588+
Use the `session_timeout` sub-option here to change the time allowed for credential validation.
2589+
2590+
Example configuration:
2591+
```yaml
2592+
ui_auth:
2593+
session_timeout: "15s"
2594+
```
2595+
---
2596+
### `login_via_existing_session`
2597+
2598+
Matrix supports the ability of an existing session to mint a login token for
2599+
another client.
2600+
2601+
Synapse disables this by default as it has security ramifications -- a malicious
2602+
client could use the mechanism to spawn more than one session.
2603+
2604+
The duration of time the generated token is valid for can be configured with the
2605+
`token_timeout` sub-option.
2606+
2607+
User-interactive authentication is required when this is enabled unless the
2608+
`require_ui_auth` sub-option is set to `False`.
2609+
2610+
Example configuration:
2611+
```yaml
2612+
login_via_existing_session:
2613+
enabled: true
2614+
require_ui_auth: false
2615+
token_timeout: "5m"
2616+
```
25742617
---
25752618
## Metrics
25762619
Config options related to metrics.
@@ -3415,28 +3458,6 @@ password_config:
34153458
require_uppercase: true
34163459
```
34173460
---
3418-
### `ui_auth`
3419-
3420-
The amount of time to allow a user-interactive authentication session to be active.
3421-
3422-
This defaults to 0, meaning the user is queried for their credentials
3423-
before every action, but this can be overridden to allow a single
3424-
validation to be re-used. This weakens the protections afforded by
3425-
the user-interactive authentication process, by allowing for multiple
3426-
(and potentially different) operations to use the same validation session.
3427-
3428-
This is ignored for potentially "dangerous" operations (including
3429-
deactivating an account, modifying an account password, and
3430-
adding a 3PID).
3431-
3432-
Use the `session_timeout` sub-option here to change the time allowed for credential validation.
3433-
3434-
Example configuration:
3435-
```yaml
3436-
ui_auth:
3437-
session_timeout: "15s"
3438-
```
3439-
---
34403461
## Push
34413462
Configuration settings related to push notifications
34423463

synapse/config/auth.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,13 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
6060
self.ui_auth_session_timeout = self.parse_duration(
6161
ui_auth.get("session_timeout", 0)
6262
)
63+
64+
# Logging in with an existing session.
65+
login_via_existing = config.get("login_via_existing_session", {})
66+
self.login_via_existing_enabled = login_via_existing.get("enabled", False)
67+
self.login_via_existing_require_ui_auth = login_via_existing.get(
68+
"require_ui_auth", True
69+
)
70+
self.login_via_existing_token_timeout = self.parse_duration(
71+
login_via_existing.get("token_timeout", "5m")
72+
)

synapse/config/experimental.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -192,10 +192,10 @@ def check_config_conflicts(self, root: RootConfig) -> None:
192192
("captcha", "enable_registration_captcha"),
193193
)
194194

195-
if root.experimental.msc3882_enabled:
195+
if root.auth.login_via_existing_enabled:
196196
raise ConfigError(
197-
"MSC3882 cannot be enabled when OAuth delegation is enabled",
198-
("experimental_features", "msc3882_enabled"),
197+
"Login via existing session cannot be enabled when OAuth delegation is enabled",
198+
("login_via_existing_session", "enabled"),
199199
)
200200

201201
if root.registration.refresh_token_lifetime:
@@ -319,13 +319,6 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
319319
# MSC3881: Remotely toggle push notifications for another client
320320
self.msc3881_enabled: bool = experimental.get("msc3881_enabled", False)
321321

322-
# MSC3882: Allow an existing session to sign in a new session
323-
self.msc3882_enabled: bool = experimental.get("msc3882_enabled", False)
324-
self.msc3882_ui_auth: bool = experimental.get("msc3882_ui_auth", True)
325-
self.msc3882_token_timeout = self.parse_duration(
326-
experimental.get("msc3882_token_timeout", "5m")
327-
)
328-
329322
# MSC3874: Filtering /messages with rel_types / not_rel_types.
330323
self.msc3874_enabled: bool = experimental.get("msc3874_enabled", False)
331324

synapse/rest/client/capabilities.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
6565
"m.3pid_changes": {
6666
"enabled": self.config.registration.enable_3pid_changes
6767
},
68+
"m.get_login_token": {
69+
"enabled": self.config.auth.login_via_existing_enabled,
70+
},
6871
}
6972
}
7073

synapse/rest/client/login.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ def __init__(self, hs: "HomeServer"):
104104
and hs.config.experimental.msc3866.require_approval_for_new_accounts
105105
)
106106

107+
# Whether get login token is enabled.
108+
self._get_login_token_enabled = hs.config.auth.login_via_existing_enabled
109+
107110
self.auth = hs.get_auth()
108111

109112
self.clock = hs.get_clock()
@@ -142,6 +145,9 @@ def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
142145
# to SSO.
143146
flows.append({"type": LoginRestServlet.CAS_TYPE})
144147

148+
# The login token flow requires m.login.token to be advertised.
149+
support_login_token_flow = self._get_login_token_enabled
150+
145151
if self.cas_enabled or self.saml2_enabled or self.oidc_enabled:
146152
flows.append(
147153
{
@@ -153,14 +159,23 @@ def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
153159
}
154160
)
155161

156-
# While it's valid for us to advertise this login type generally,
157-
# synapse currently only gives out these tokens as part of the
158-
# SSO login flow.
159-
# Generally we don't want to advertise login flows that clients
160-
# don't know how to implement, since they (currently) will always
161-
# fall back to the fallback API if they don't understand one of the
162-
# login flow types returned.
163-
flows.append({"type": LoginRestServlet.TOKEN_TYPE})
162+
# SSO requires a login token to be generated, so we need to advertise that flow
163+
support_login_token_flow = True
164+
165+
# While it's valid for us to advertise this login type generally,
166+
# synapse currently only gives out these tokens as part of the
167+
# SSO login flow or as part of login via an existing session.
168+
#
169+
# Generally we don't want to advertise login flows that clients
170+
# don't know how to implement, since they (currently) will always
171+
# fall back to the fallback API if they don't understand one of the
172+
# login flow types returned.
173+
if support_login_token_flow:
174+
tokenTypeFlow: Dict[str, Any] = {"type": LoginRestServlet.TOKEN_TYPE}
175+
# If the login token flow is enabled advertise the get_login_token flag.
176+
if self._get_login_token_enabled:
177+
tokenTypeFlow["get_login_token"] = True
178+
flows.append(tokenTypeFlow)
164179

165180
flows.extend({"type": t} for t in self.auth_handler.get_supported_login_types())
166181

synapse/rest/client/login_token_request.py

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import logging
1616
from typing import TYPE_CHECKING, Tuple
1717

18+
from synapse.api.ratelimiting import Ratelimiter
1819
from synapse.http.server import HttpServer
1920
from synapse.http.servlet import RestServlet, parse_json_object_from_request
2021
from synapse.http.site import SynapseRequest
@@ -33,7 +34,7 @@ class LoginTokenRequestServlet(RestServlet):
3334
3435
Request:
3536
36-
POST /login/token HTTP/1.1
37+
POST /login/get_token HTTP/1.1
3738
Content-Type: application/json
3839
3940
{}
@@ -43,30 +44,45 @@ class LoginTokenRequestServlet(RestServlet):
4344
HTTP/1.1 200 OK
4445
{
4546
"login_token": "ABDEFGH",
46-
"expires_in": 3600,
47+
"expires_in_ms": 3600000,
4748
}
4849
"""
4950

50-
PATTERNS = client_patterns(
51-
"/org.matrix.msc3882/login/token$", releases=[], v1=False, unstable=True
52-
)
51+
PATTERNS = [
52+
*client_patterns(
53+
"/login/get_token$", releases=["v1"], v1=False, unstable=False
54+
),
55+
# TODO: this is no longer needed once unstable MSC3882 does not need to be supported:
56+
*client_patterns(
57+
"/org.matrix.msc3882/login/token$", releases=[], v1=False, unstable=True
58+
),
59+
]
5360

5461
def __init__(self, hs: "HomeServer"):
5562
super().__init__()
5663
self.auth = hs.get_auth()
57-
self.store = hs.get_datastores().main
58-
self.clock = hs.get_clock()
59-
self.server_name = hs.config.server.server_name
64+
self._main_store = hs.get_datastores().main
6065
self.auth_handler = hs.get_auth_handler()
61-
self.token_timeout = hs.config.experimental.msc3882_token_timeout
62-
self.ui_auth = hs.config.experimental.msc3882_ui_auth
66+
self.token_timeout = hs.config.auth.login_via_existing_token_timeout
67+
self._require_ui_auth = hs.config.auth.login_via_existing_require_ui_auth
68+
69+
# Ratelimit aggressively to a maxmimum of 1 request per minute.
70+
#
71+
# This endpoint can be used to spawn additional sessions and could be
72+
# abused by a malicious client to create many sessions.
73+
self._ratelimiter = Ratelimiter(
74+
store=self._main_store,
75+
clock=hs.get_clock(),
76+
rate_hz=1 / 60,
77+
burst_count=1,
78+
)
6379

6480
@interactive_auth_handler
6581
async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
6682
requester = await self.auth.get_user_by_req(request)
6783
body = parse_json_object_from_request(request)
6884

69-
if self.ui_auth:
85+
if self._require_ui_auth:
7086
await self.auth_handler.validate_user_via_ui_auth(
7187
requester,
7288
request,
@@ -75,21 +91,26 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
7591
can_skip_ui_auth=False, # Don't allow skipping of UI auth
7692
)
7793

94+
# Ensure that this endpoint isn't being used too often. (Ensure this is
95+
# done *after* UI auth.)
96+
await self._ratelimiter.ratelimit(None, requester.user.to_string().lower())
97+
7898
login_token = await self.auth_handler.create_login_token_for_user_id(
7999
user_id=requester.user.to_string(),
80-
auth_provider_id="org.matrix.msc3882.login_token_request",
81100
duration_ms=self.token_timeout,
82101
)
83102

84103
return (
85104
200,
86105
{
87106
"login_token": login_token,
107+
# TODO: this is no longer needed once unstable MSC3882 does not need to be supported:
88108
"expires_in": self.token_timeout // 1000,
109+
"expires_in_ms": self.token_timeout,
89110
},
90111
)
91112

92113

93114
def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
94-
if hs.config.experimental.msc3882_enabled:
115+
if hs.config.auth.login_via_existing_enabled:
95116
LoginTokenRequestServlet(hs).register(http_server)

synapse/rest/client/versions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ def on_GET(self, request: Request) -> Tuple[int, JsonDict]:
113113
"fi.mau.msc2815": self.config.experimental.msc2815_enabled,
114114
# Adds a ping endpoint for appservices to check HS->AS connection
115115
"fi.mau.msc2659.stable": True, # TODO: remove when "v1.7" is added above
116-
# Adds support for login token requests as per MSC3882
117-
"org.matrix.msc3882": self.config.experimental.msc3882_enabled,
116+
# TODO: this is no longer needed once unstable MSC3882 does not need to be supported:
117+
"org.matrix.msc3882": self.config.auth.login_via_existing_enabled,
118118
# Adds support for remotely enabling/disabling pushers, as per MSC3881
119119
"org.matrix.msc3881": self.config.experimental.msc3881_enabled,
120120
# Adds support for filtering /messages by event relation.

tests/config/test_oauth_delegation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,8 @@ def test_jwt_auth_cannot_be_enabled(self) -> None:
228228
with self.assertRaises(ConfigError):
229229
self.parse_config()
230230

231-
def test_msc3882_auth_cannot_be_enabled(self) -> None:
232-
self.config_dict["experimental_features"]["msc3882_enabled"] = True
231+
def test_login_via_existing_session_cannot_be_enabled(self) -> None:
232+
self.config_dict["login_via_existing_session"] = {"enabled": True}
233233
with self.assertRaises(ConfigError):
234234
self.parse_config()
235235

tests/rest/client/test_capabilities.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,31 @@ def test_get_does_include_msc3244_fields_when_enabled(self) -> None:
186186
self.assertGreater(len(details["support"]), 0)
187187
for room_version in details["support"]:
188188
self.assertTrue(room_version in KNOWN_ROOM_VERSIONS, str(room_version))
189+
190+
def test_get_get_token_login_fields_when_disabled(self) -> None:
191+
"""By default login via an existing session is disabled."""
192+
access_token = self.get_success(
193+
self.auth_handler.create_access_token_for_user_id(
194+
self.user, device_id=None, valid_until_ms=None
195+
)
196+
)
197+
198+
channel = self.make_request("GET", self.url, access_token=access_token)
199+
capabilities = channel.json_body["capabilities"]
200+
201+
self.assertEqual(channel.code, HTTPStatus.OK)
202+
self.assertFalse(capabilities["m.get_login_token"]["enabled"])
203+
204+
@override_config({"login_via_existing_session": {"enabled": True}})
205+
def test_get_get_token_login_fields_when_enabled(self) -> None:
206+
access_token = self.get_success(
207+
self.auth_handler.create_access_token_for_user_id(
208+
self.user, device_id=None, valid_until_ms=None
209+
)
210+
)
211+
212+
channel = self.make_request("GET", self.url, access_token=access_token)
213+
capabilities = channel.json_body["capabilities"]
214+
215+
self.assertEqual(channel.code, HTTPStatus.OK)
216+
self.assertTrue(capabilities["m.get_login_token"]["enabled"])

tests/rest/client/test_login.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,29 @@ def test_require_approval(self) -> None:
446446
ApprovalNoticeMedium.NONE, channel.json_body["approval_notice_medium"]
447447
)
448448

449+
def test_get_login_flows_with_login_via_existing_disabled(self) -> None:
450+
"""GET /login should return m.login.token without get_login_token"""
451+
channel = self.make_request("GET", "/_matrix/client/r0/login")
452+
self.assertEqual(channel.code, 200, channel.result)
453+
454+
flows = {flow["type"]: flow for flow in channel.json_body["flows"]}
455+
self.assertNotIn("m.login.token", flows)
456+
457+
@override_config({"login_via_existing_session": {"enabled": True}})
458+
def test_get_login_flows_with_login_via_existing_enabled(self) -> None:
459+
"""GET /login should return m.login.token with get_login_token true"""
460+
channel = self.make_request("GET", "/_matrix/client/r0/login")
461+
self.assertEqual(channel.code, 200, channel.result)
462+
463+
self.assertCountEqual(
464+
channel.json_body["flows"],
465+
[
466+
{"type": "m.login.token", "get_login_token": True},
467+
{"type": "m.login.password"},
468+
{"type": "m.login.application_service"},
469+
],
470+
)
471+
449472

450473
@skip_unless(has_saml2 and HAS_OIDC, "Requires SAML2 and OIDC")
451474
class MultiSSOTestCase(unittest.HomeserverTestCase):

0 commit comments

Comments
 (0)