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

Commit 0640f8e

Browse files
authored
Add a callback to allow modules to deny 3PID (#11854)
Part of the Tchap Synapse mainlining. This allows modules to implement extra logic to figure out whether a given 3PID can be added to the local homeserver. In the Tchap use case, this will allow a Synapse module to interface with the custom endpoint /internal_info.
1 parent fef2e79 commit 0640f8e

File tree

8 files changed

+161
-7
lines changed

8 files changed

+161
-7
lines changed

changelog.d/11854.feature

Lines changed: 1 addition & 0 deletions
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

Lines changed: 19 additions & 0 deletions
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

synapse/handlers/auth.py

Lines changed: 44 additions & 0 deletions
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

Lines changed: 3 additions & 0 deletions
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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
385385
send_attempt = body["send_attempt"]
386386
next_link = body.get("next_link") # Optional param
387387

388-
if not check_3pid_allowed(self.hs, "email", email):
388+
if not await check_3pid_allowed(self.hs, "email", email):
389389
raise SynapseError(
390390
403,
391391
"Your email domain is not authorized on this server",
@@ -468,7 +468,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
468468

469469
msisdn = phone_number_to_msisdn(country, phone_number)
470470

471-
if not check_3pid_allowed(self.hs, "msisdn", msisdn):
471+
if not await check_3pid_allowed(self.hs, "msisdn", msisdn):
472472
raise SynapseError(
473473
403,
474474
"Account phone numbers are not authorized on this server",

synapse/rest/client/register.py

Lines changed: 5 additions & 3 deletions
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 check_3pid_allowed(self.hs, "email", email):
115+
if not await check_3pid_allowed(self.hs, "email", email, registration=True):
116116
raise SynapseError(
117117
403,
118118
"Your email domain is not authorized to register on this server",
@@ -192,7 +192,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
192192

193193
msisdn = phone_number_to_msisdn(country, phone_number)
194194

195-
if not check_3pid_allowed(self.hs, "msisdn", msisdn):
195+
if not await check_3pid_allowed(self.hs, "msisdn", msisdn, registration=True):
196196
raise SynapseError(
197197
403,
198198
"Phone numbers are not authorized to register on this server",
@@ -616,7 +616,9 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
616616
medium = auth_result[login_type]["medium"]
617617
address = auth_result[login_type]["address"]
618618

619-
if not check_3pid_allowed(self.hs, medium, address):
619+
if not await check_3pid_allowed(
620+
self.hs, medium, address, registration=True
621+
):
620622
raise SynapseError(
621623
403,
622624
"Third party identifiers (email/phone numbers)"

synapse/util/threepids.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,28 @@
3232
MAX_EMAIL_ADDRESS_LENGTH = 500
3333

3434

35-
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
"""
53+
if not await hs.get_password_auth_provider().is_3pid_allowed(
54+
medium, address, registration
55+
):
56+
return False
4657

4758
if hs.config.registration.allowed_local_3pids:
4859
for constraint in hs.config.registration.allowed_local_3pids:

tests/handlers/test_password_providers.py

Lines changed: 75 additions & 1 deletion
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)