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

Commit f20caae

Browse files
authored
Merge pull request #124 from matrix-org/babolivier/3pid_callback
Add a callback to allow modules to deny 3PID (#11854)
2 parents 8942200 + 92f7c6a commit f20caae

File tree

10 files changed

+161
-61
lines changed

10 files changed

+161
-61
lines changed

changelog.d/11854.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a callback to allow modules to allow or forbid a 3PID (email address, phone number) from being associated to a local account.

docs/modules/password_auth_provider_callbacks.md

+19
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,25 @@ any of the subsequent implementations of this callback. If every callback return
166166
the username provided by the user is used, if any (otherwise one is automatically
167167
generated).
168168

169+
## `is_3pid_allowed`
170+
171+
_First introduced in Synapse v1.53.0_
172+
173+
```python
174+
async def is_3pid_allowed(self, medium: str, address: str, registration: bool) -> bool
175+
```
176+
177+
Called when attempting to bind a third-party identifier (i.e. an email address or a phone
178+
number). The module is given the medium of the third-party identifier (which is `email` if
179+
the identifier is an email address, or `msisdn` if the identifier is a phone number) and
180+
its address, as well as a boolean indicating whether the attempt to bind is happening as
181+
part of registering a new user. The module must return a boolean indicating whether the
182+
identifier can be allowed to be bound to an account on the local homeserver.
183+
184+
If multiple modules implement this callback, they will be considered in order. If a
185+
callback returns `True`, Synapse falls through to the next one. The value of the first
186+
callback that does not return `True` will be used. If this happens, Synapse will not call
187+
any of the subsequent implementations of this callback.
169188

170189
## Example
171190

docs/sample_config.yaml

-11
Original file line numberDiff line numberDiff line change
@@ -1290,17 +1290,6 @@ oembed:
12901290
# Mandate that users are only allowed to associate certain formats of
12911291
# 3PIDs with accounts on this server.
12921292
#
1293-
# Use an Identity Server to establish which 3PIDs are allowed to register?
1294-
# Overrides allowed_local_3pids below.
1295-
#
1296-
#check_is_for_allowed_local_3pids: matrix.org
1297-
#
1298-
# If you are using an IS you can also check whether that IS registers
1299-
# pending invites for the given 3PID (and then allow it to sign up on
1300-
# the platform):
1301-
#
1302-
#allow_invited_3pids: false
1303-
#
13041293
#allowed_local_3pids:
13051294
# - medium: email
13061295
# pattern: '^[^@]+@matrix\.org$'

synapse/config/registration.py

-15
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,6 @@ def read_config(self, config, **kwargs):
3535

3636
self.registrations_require_3pid = config.get("registrations_require_3pid", [])
3737
self.allowed_local_3pids = config.get("allowed_local_3pids", [])
38-
self.check_is_for_allowed_local_3pids = config.get(
39-
"check_is_for_allowed_local_3pids", None
40-
)
41-
self.allow_invited_3pids = config.get("allow_invited_3pids", False)
4238

4339
self.disable_3pid_changes = config.get("disable_3pid_changes", False)
4440

@@ -321,17 +317,6 @@ def generate_config_section(self, generate_secrets=False, **kwargs):
321317
# Mandate that users are only allowed to associate certain formats of
322318
# 3PIDs with accounts on this server.
323319
#
324-
# Use an Identity Server to establish which 3PIDs are allowed to register?
325-
# Overrides allowed_local_3pids below.
326-
#
327-
#check_is_for_allowed_local_3pids: matrix.org
328-
#
329-
# If you are using an IS you can also check whether that IS registers
330-
# pending invites for the given 3PID (and then allow it to sign up on
331-
# the platform):
332-
#
333-
#allow_invited_3pids: false
334-
#
335320
#allowed_local_3pids:
336321
# - medium: email
337322
# pattern: '^[^@]+@matrix\\.org$'

synapse/handlers/auth.py

+44
Original file line numberDiff line numberDiff line change
@@ -2064,6 +2064,7 @@ def run(*args: Tuple, **kwargs: Dict) -> Awaitable:
20642064
[JsonDict, JsonDict],
20652065
Awaitable[Optional[str]],
20662066
]
2067+
IS_3PID_ALLOWED_CALLBACK = Callable[[str, str, bool], Awaitable[bool]]
20672068

20682069

20692070
class PasswordAuthProvider:
@@ -2079,6 +2080,7 @@ def __init__(self) -> None:
20792080
self.get_username_for_registration_callbacks: List[
20802081
GET_USERNAME_FOR_REGISTRATION_CALLBACK
20812082
] = []
2083+
self.is_3pid_allowed_callbacks: List[IS_3PID_ALLOWED_CALLBACK] = []
20822084

20832085
# Mapping from login type to login parameters
20842086
self._supported_login_types: Dict[str, Iterable[str]] = {}
@@ -2090,6 +2092,7 @@ def register_password_auth_provider_callbacks(
20902092
self,
20912093
check_3pid_auth: Optional[CHECK_3PID_AUTH_CALLBACK] = None,
20922094
on_logged_out: Optional[ON_LOGGED_OUT_CALLBACK] = None,
2095+
is_3pid_allowed: Optional[IS_3PID_ALLOWED_CALLBACK] = None,
20932096
auth_checkers: Optional[
20942097
Dict[Tuple[str, Tuple[str, ...]], CHECK_AUTH_CALLBACK]
20952098
] = None,
@@ -2145,6 +2148,9 @@ def register_password_auth_provider_callbacks(
21452148
get_username_for_registration,
21462149
)
21472150

2151+
if is_3pid_allowed is not None:
2152+
self.is_3pid_allowed_callbacks.append(is_3pid_allowed)
2153+
21482154
def get_supported_login_types(self) -> Mapping[str, Iterable[str]]:
21492155
"""Get the login types supported by this password provider
21502156
@@ -2343,3 +2349,41 @@ async def get_username_for_registration(
23432349
raise SynapseError(code=500, msg="Internal Server Error")
23442350

23452351
return None
2352+
2353+
async def is_3pid_allowed(
2354+
self,
2355+
medium: str,
2356+
address: str,
2357+
registration: bool,
2358+
) -> bool:
2359+
"""Check if the user can be allowed to bind a 3PID on this homeserver.
2360+
2361+
Args:
2362+
medium: The medium of the 3PID.
2363+
address: The address of the 3PID.
2364+
registration: Whether the 3PID is being bound when registering a new user.
2365+
2366+
Returns:
2367+
Whether the 3PID is allowed to be bound on this homeserver
2368+
"""
2369+
for callback in self.is_3pid_allowed_callbacks:
2370+
try:
2371+
res = await callback(medium, address, registration)
2372+
2373+
if res is False:
2374+
return res
2375+
elif not isinstance(res, bool):
2376+
# mypy complains that this line is unreachable because it assumes the
2377+
# data returned by the module fits the expected type. We just want
2378+
# to make sure this is the case.
2379+
logger.warning( # type: ignore[unreachable]
2380+
"Ignoring non-string value returned by"
2381+
" is_3pid_allowed callback %s: %s",
2382+
callback,
2383+
res,
2384+
)
2385+
except Exception as e:
2386+
logger.error("Module raised an exception in is_3pid_allowed: %s", e)
2387+
raise SynapseError(code=500, msg="Internal Server Error")
2388+
2389+
return True

synapse/module_api/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
CHECK_3PID_AUTH_CALLBACK,
7373
CHECK_AUTH_CALLBACK,
7474
GET_USERNAME_FOR_REGISTRATION_CALLBACK,
75+
IS_3PID_ALLOWED_CALLBACK,
7576
ON_LOGGED_OUT_CALLBACK,
7677
AuthHandler,
7778
)
@@ -312,6 +313,7 @@ def register_password_auth_provider_callbacks(
312313
auth_checkers: Optional[
313314
Dict[Tuple[str, Tuple[str, ...]], CHECK_AUTH_CALLBACK]
314315
] = None,
316+
is_3pid_allowed: Optional[IS_3PID_ALLOWED_CALLBACK] = None,
315317
get_username_for_registration: Optional[
316318
GET_USERNAME_FOR_REGISTRATION_CALLBACK
317319
] = None,
@@ -323,6 +325,7 @@ def register_password_auth_provider_callbacks(
323325
return self._password_auth_provider.register_password_auth_provider_callbacks(
324326
check_3pid_auth=check_3pid_auth,
325327
on_logged_out=on_logged_out,
328+
is_3pid_allowed=is_3pid_allowed,
326329
auth_checkers=auth_checkers,
327330
get_username_for_registration=get_username_for_registration,
328331
)

synapse/rest/client/account.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
394394
send_attempt = body["send_attempt"]
395395
next_link = body.get("next_link") # Optional param
396396

397-
if not (await check_3pid_allowed(self.hs, "email", email)):
397+
if not await check_3pid_allowed(self.hs, "email", email):
398398
raise SynapseError(
399399
403,
400400
"Your email is not authorized on this server",
@@ -477,7 +477,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
477477

478478
msisdn = phone_number_to_msisdn(country, phone_number)
479479

480-
if not (await check_3pid_allowed(self.hs, "msisdn", msisdn)):
480+
if not await check_3pid_allowed(self.hs, "msisdn", msisdn):
481481
raise SynapseError(
482482
403,
483483
"Account phone numbers are not authorized on this server",

synapse/rest/client/register.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
112112
send_attempt = body["send_attempt"]
113113
next_link = body.get("next_link") # Optional param
114114

115-
if not (await check_3pid_allowed(self.hs, "email", body["email"])):
115+
if not await check_3pid_allowed(self.hs, "email", email, registration=True):
116116
raise SynapseError(
117117
403,
118118
"Your email is not authorized to register on this server",
@@ -192,9 +192,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
192192

193193
msisdn = phone_number_to_msisdn(country, phone_number)
194194

195-
assert_valid_client_secret(body["client_secret"])
196-
197-
if not (await check_3pid_allowed(self.hs, "msisdn", msisdn)):
195+
if not await check_3pid_allowed(self.hs, "msisdn", msisdn, registration=True):
198196
raise SynapseError(
199197
403,
200198
"Phone numbers are not authorized to register on this server",
@@ -619,7 +617,9 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
619617
medium = auth_result[login_type]["medium"]
620618
address = auth_result[login_type]["address"]
621619

622-
if not (await check_3pid_allowed(self.hs, medium, address)):
620+
if not await check_3pid_allowed(
621+
self.hs, medium, address, registration=True
622+
):
623623
raise SynapseError(
624624
403,
625625
"Third party identifiers (email/phone numbers)"

synapse/util/threepids.py

+12-27
Original file line numberDiff line numberDiff line change
@@ -32,43 +32,28 @@
3232
MAX_EMAIL_ADDRESS_LENGTH = 500
3333

3434

35-
async def check_3pid_allowed(hs: "HomeServer", medium: str, address: str) -> bool:
35+
async def check_3pid_allowed(
36+
hs: "HomeServer",
37+
medium: str,
38+
address: str,
39+
registration: bool = False,
40+
) -> bool:
3641
"""Checks whether a given format of 3PID is allowed to be used on this HS
3742
3843
Args:
3944
hs: server
4045
medium: 3pid medium - e.g. email, msisdn
4146
address: address within that medium (e.g. "[email protected]")
4247
msisdns need to first have been canonicalised
48+
registration: whether we want to bind the 3PID as part of registering a new user.
49+
4350
Returns:
4451
bool: whether the 3PID medium/address is allowed to be added to this HS
4552
"""
46-
if hs.config.registration.check_is_for_allowed_local_3pids:
47-
data = await hs.get_simple_http_client().get_json(
48-
"https://%s%s"
49-
% (
50-
hs.config.registration.check_is_for_allowed_local_3pids,
51-
"/_matrix/identity/api/v1/internal-info",
52-
),
53-
{"medium": medium, "address": address},
54-
)
55-
56-
# Check for invalid response
57-
if "hs" not in data and "shadow_hs" not in data:
58-
return False
59-
60-
# Check if this user is intended to register for this homeserver
61-
if (
62-
data.get("hs") != hs.config.server.server_name
63-
and data.get("shadow_hs") != hs.config.server.server_name
64-
):
65-
return False
66-
67-
if data.get("requires_invite", False) and not data.get("invited", False):
68-
# Requires an invite but hasn't been invited
69-
return False
70-
71-
return True
53+
if not await hs.get_password_auth_provider().is_3pid_allowed(
54+
medium, address, registration
55+
):
56+
return False
7257

7358
if hs.config.registration.allowed_local_3pids:
7459
for constraint in hs.config.registration.allowed_local_3pids:

tests/handlers/test_password_providers.py

+75-1
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@
2121

2222
import synapse
2323
from synapse.api.constants import LoginType
24+
from synapse.api.errors import Codes
2425
from synapse.handlers.auth import load_legacy_password_auth_providers
2526
from synapse.module_api import ModuleApi
26-
from synapse.rest.client import devices, login, logout, register
27+
from synapse.rest.client import account, devices, login, logout, register
2728
from synapse.types import JsonDict, UserID
2829

2930
from tests import unittest
3031
from tests.server import FakeChannel
32+
from tests.test_utils import make_awaitable
3133
from tests.unittest import override_config
3234

3335
# (possibly experimental) login flows we expect to appear in the list after the normal
@@ -158,6 +160,7 @@ class PasswordAuthProviderTests(unittest.HomeserverTestCase):
158160
devices.register_servlets,
159161
logout.register_servlets,
160162
register.register_servlets,
163+
account.register_servlets,
161164
]
162165

163166
def setUp(self):
@@ -803,6 +806,77 @@ def test_username_uia(self):
803806
# Check that the callback has been called.
804807
m.assert_called_once()
805808

809+
# Set some email configuration so the test doesn't fail because of its absence.
810+
@override_config({"email": {"notif_from": "noreply@test"}})
811+
def test_3pid_allowed(self):
812+
"""Tests that an is_3pid_allowed_callbacks forbidding a 3PID makes Synapse refuse
813+
to bind the new 3PID, and that one allowing a 3PID makes Synapse accept to bind
814+
the 3PID. Also checks that the module is passed a boolean indicating whether the
815+
user to bind this 3PID to is currently registering.
816+
"""
817+
self._test_3pid_allowed("rin", False)
818+
self._test_3pid_allowed("kitay", True)
819+
820+
def _test_3pid_allowed(self, username: str, registration: bool):
821+
"""Tests that the "is_3pid_allowed" module callback is called correctly, using
822+
either /register or /account URLs depending on the arguments.
823+
824+
Args:
825+
username: The username to use for the test.
826+
registration: Whether to test with registration URLs.
827+
"""
828+
self.hs.get_identity_handler().send_threepid_validation = Mock(
829+
return_value=make_awaitable(0),
830+
)
831+
832+
m = Mock(return_value=make_awaitable(False))
833+
self.hs.get_password_auth_provider().is_3pid_allowed_callbacks = [m]
834+
835+
self.register_user(username, "password")
836+
tok = self.login(username, "password")
837+
838+
if registration:
839+
url = "/register/email/requestToken"
840+
else:
841+
url = "/account/3pid/email/requestToken"
842+
843+
channel = self.make_request(
844+
"POST",
845+
url,
846+
{
847+
"client_secret": "foo",
848+
"email": "[email protected]",
849+
"send_attempt": 0,
850+
},
851+
access_token=tok,
852+
)
853+
self.assertEqual(channel.code, 403, channel.result)
854+
self.assertEqual(
855+
channel.json_body["errcode"],
856+
Codes.THREEPID_DENIED,
857+
channel.json_body,
858+
)
859+
860+
m.assert_called_once_with("email", "[email protected]", registration)
861+
862+
m = Mock(return_value=make_awaitable(True))
863+
self.hs.get_password_auth_provider().is_3pid_allowed_callbacks = [m]
864+
865+
channel = self.make_request(
866+
"POST",
867+
url,
868+
{
869+
"client_secret": "foo",
870+
"email": "[email protected]",
871+
"send_attempt": 0,
872+
},
873+
access_token=tok,
874+
)
875+
self.assertEqual(channel.code, 200, channel.result)
876+
self.assertIn("sid", channel.json_body)
877+
878+
m.assert_called_once_with("email", "[email protected]", registration)
879+
806880
def _setup_get_username_for_registration(self) -> Mock:
807881
"""Registers a get_username_for_registration callback that appends "-foo" to the
808882
username the client is trying to register.

0 commit comments

Comments
 (0)