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

Commit 0fd2f2d

Browse files
authored
Implementation of MSC3882 login token request (#13722)
1 parent 269edda commit 0fd2f2d

File tree

6 files changed

+238
-0
lines changed

6 files changed

+238
-0
lines changed

changelog.d/13722.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Experimental implementation of MSC3882 to allow an existing device/session to generate a login token for use on a new device/session.

synapse/config/experimental.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,10 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
9696

9797
# MSC3881: Remotely toggle push notifications for another client
9898
self.msc3881_enabled: bool = experimental.get("msc3881_enabled", False)
99+
100+
# MSC3882: Allow an existing session to sign in a new session
101+
self.msc3882_enabled: bool = experimental.get("msc3882_enabled", False)
102+
self.msc3882_ui_auth: bool = experimental.get("msc3882_ui_auth", True)
103+
self.msc3882_token_timeout = self.parse_duration(
104+
experimental.get("msc3882_token_timeout", "5m")
105+
)

synapse/rest/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
keys,
3131
knock,
3232
login as v1_login,
33+
login_token_request,
3334
logout,
3435
mutual_rooms,
3536
notifications,
@@ -130,3 +131,4 @@ def register_servlets(client_resource: HttpServer, hs: "HomeServer") -> None:
130131

131132
# unstable
132133
mutual_rooms.register_servlets(hs, client_resource)
134+
login_token_request.register_servlets(hs, client_resource)
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Copyright 2022 The Matrix.org Foundation C.I.C.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import logging
16+
from typing import TYPE_CHECKING, Tuple
17+
18+
from synapse.http.server import HttpServer
19+
from synapse.http.servlet import RestServlet, parse_json_object_from_request
20+
from synapse.http.site import SynapseRequest
21+
from synapse.rest.client._base import client_patterns, interactive_auth_handler
22+
from synapse.types import JsonDict
23+
24+
if TYPE_CHECKING:
25+
from synapse.server import HomeServer
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
class LoginTokenRequestServlet(RestServlet):
31+
"""
32+
Get a token that can be used with `m.login.token` to log in a second device.
33+
34+
Request:
35+
36+
POST /login/token HTTP/1.1
37+
Content-Type: application/json
38+
39+
{}
40+
41+
Response:
42+
43+
HTTP/1.1 200 OK
44+
{
45+
"login_token": "ABDEFGH",
46+
"expires_in": 3600,
47+
}
48+
"""
49+
50+
PATTERNS = client_patterns("/login/token$")
51+
52+
def __init__(self, hs: "HomeServer"):
53+
super().__init__()
54+
self.auth = hs.get_auth()
55+
self.store = hs.get_datastores().main
56+
self.clock = hs.get_clock()
57+
self.server_name = hs.config.server.server_name
58+
self.macaroon_gen = hs.get_macaroon_generator()
59+
self.auth_handler = hs.get_auth_handler()
60+
self.token_timeout = hs.config.experimental.msc3882_token_timeout
61+
self.ui_auth = hs.config.experimental.msc3882_ui_auth
62+
63+
@interactive_auth_handler
64+
async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
65+
requester = await self.auth.get_user_by_req(request)
66+
body = parse_json_object_from_request(request)
67+
68+
if self.ui_auth:
69+
await self.auth_handler.validate_user_via_ui_auth(
70+
requester,
71+
request,
72+
body,
73+
"issue a new access token for your account",
74+
can_skip_ui_auth=False, # Don't allow skipping of UI auth
75+
)
76+
77+
login_token = self.macaroon_gen.generate_short_term_login_token(
78+
user_id=requester.user.to_string(),
79+
auth_provider_id="org.matrix.msc3882.login_token_request",
80+
duration_in_ms=self.token_timeout,
81+
)
82+
83+
return (
84+
200,
85+
{
86+
"login_token": login_token,
87+
"expires_in": self.token_timeout // 1000,
88+
},
89+
)
90+
91+
92+
def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
93+
if hs.config.experimental.msc3882_enabled:
94+
LoginTokenRequestServlet(hs).register(http_server)

synapse/rest/client/versions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ def on_GET(self, request: Request) -> Tuple[int, JsonDict]:
105105
"org.matrix.msc3440.stable": True, # TODO: remove when "v1.3" is added above
106106
# Allows moderators to fetch redacted event content as described in MSC2815
107107
"fi.mau.msc2815": self.config.experimental.msc2815_enabled,
108+
# Adds support for login token requests as per MSC3882
109+
"org.matrix.msc3882": self.config.experimental.msc3882_enabled,
108110
},
109111
},
110112
)
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Copyright 2022 The Matrix.org Foundation C.I.C.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from twisted.test.proto_helpers import MemoryReactor
16+
17+
from synapse.rest import admin
18+
from synapse.rest.client import login, login_token_request
19+
from synapse.server import HomeServer
20+
from synapse.util import Clock
21+
22+
from tests import unittest
23+
from tests.unittest import override_config
24+
25+
26+
class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
27+
28+
servlets = [
29+
login.register_servlets,
30+
admin.register_servlets,
31+
login_token_request.register_servlets,
32+
]
33+
34+
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
35+
self.hs = self.setup_test_homeserver()
36+
self.hs.config.registration.enable_registration = True
37+
self.hs.config.registration.registrations_require_3pid = []
38+
self.hs.config.registration.auto_join_rooms = []
39+
self.hs.config.captcha.enable_registration_captcha = False
40+
41+
return self.hs
42+
43+
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
44+
self.user = "user123"
45+
self.password = "password"
46+
47+
def test_disabled(self) -> None:
48+
channel = self.make_request("POST", "/login/token", {}, access_token=None)
49+
self.assertEqual(channel.code, 400)
50+
51+
self.register_user(self.user, self.password)
52+
token = self.login(self.user, self.password)
53+
54+
channel = self.make_request("POST", "/login/token", {}, access_token=token)
55+
self.assertEqual(channel.code, 400)
56+
57+
@override_config({"experimental_features": {"msc3882_enabled": True}})
58+
def test_require_auth(self) -> None:
59+
channel = self.make_request("POST", "/login/token", {}, access_token=None)
60+
self.assertEqual(channel.code, 401)
61+
62+
@override_config({"experimental_features": {"msc3882_enabled": True}})
63+
def test_uia_on(self) -> None:
64+
user_id = self.register_user(self.user, self.password)
65+
token = self.login(self.user, self.password)
66+
67+
channel = self.make_request("POST", "/login/token", {}, access_token=token)
68+
self.assertEqual(channel.code, 401)
69+
self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
70+
71+
session = channel.json_body["session"]
72+
73+
uia = {
74+
"auth": {
75+
"type": "m.login.password",
76+
"identifier": {"type": "m.id.user", "user": self.user},
77+
"password": self.password,
78+
"session": session,
79+
},
80+
}
81+
82+
channel = self.make_request("POST", "/login/token", uia, access_token=token)
83+
self.assertEqual(channel.code, 200)
84+
self.assertEqual(channel.json_body["expires_in"], 300)
85+
86+
login_token = channel.json_body["login_token"]
87+
88+
channel = self.make_request(
89+
"POST",
90+
"/login",
91+
content={"type": "m.login.token", "token": login_token},
92+
)
93+
self.assertEqual(channel.code, 200, channel.result)
94+
self.assertEqual(channel.json_body["user_id"], user_id)
95+
96+
@override_config(
97+
{"experimental_features": {"msc3882_enabled": True, "msc3882_ui_auth": False}}
98+
)
99+
def test_uia_off(self) -> None:
100+
user_id = self.register_user(self.user, self.password)
101+
token = self.login(self.user, self.password)
102+
103+
channel = self.make_request("POST", "/login/token", {}, access_token=token)
104+
self.assertEqual(channel.code, 200)
105+
self.assertEqual(channel.json_body["expires_in"], 300)
106+
107+
login_token = channel.json_body["login_token"]
108+
109+
channel = self.make_request(
110+
"POST",
111+
"/login",
112+
content={"type": "m.login.token", "token": login_token},
113+
)
114+
self.assertEqual(channel.code, 200, channel.result)
115+
self.assertEqual(channel.json_body["user_id"], user_id)
116+
117+
@override_config(
118+
{
119+
"experimental_features": {
120+
"msc3882_enabled": True,
121+
"msc3882_ui_auth": False,
122+
"msc3882_token_timeout": "15s",
123+
}
124+
}
125+
)
126+
def test_expires_in(self) -> None:
127+
self.register_user(self.user, self.password)
128+
token = self.login(self.user, self.password)
129+
130+
channel = self.make_request("POST", "/login/token", {}, access_token=token)
131+
self.assertEqual(channel.code, 200)
132+
self.assertEqual(channel.json_body["expires_in"], 15)

0 commit comments

Comments
 (0)