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

Commit 25c55a9

Browse files
authored
Add login spam checker API (#15838)
1 parent 52d8131 commit 25c55a9

File tree

7 files changed

+285
-6
lines changed

7 files changed

+285
-6
lines changed

changelog.d/15838.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add spam checker module API for logins.

docs/modules/spam_checker_callbacks.md

+36
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,42 @@ callback returns `False`, Synapse falls through to the next one. The value of th
348348
callback that does not return `False` will be used. If this happens, Synapse will not call
349349
any of the subsequent implementations of this callback.
350350

351+
352+
### `check_login_for_spam`
353+
354+
_First introduced in Synapse v1.87.0_
355+
356+
```python
357+
async def check_login_for_spam(
358+
user_id: str,
359+
device_id: Optional[str],
360+
initial_display_name: Optional[str],
361+
request_info: Collection[Tuple[Optional[str], str]],
362+
auth_provider_id: Optional[str] = None,
363+
) -> Union["synapse.module_api.NOT_SPAM", "synapse.module_api.errors.Codes"]
364+
```
365+
366+
Called when a user logs in.
367+
368+
The arguments passed to this callback are:
369+
370+
* `user_id`: The user ID the user is logging in with
371+
* `device_id`: The device ID the user is re-logging into.
372+
* `initial_display_name`: The device display name, if any.
373+
* `request_info`: A collection of tuples, which first item is a user agent, and which
374+
second item is an IP address. These user agents and IP addresses are the ones that were
375+
used during the login process.
376+
* `auth_provider_id`: The identifier of the SSO authentication provider, if any.
377+
378+
If multiple modules implement this callback, they will be considered in order. If a
379+
callback returns `synapse.module_api.NOT_SPAM`, Synapse falls through to the next one.
380+
The value of the first callback that does not return `synapse.module_api.NOT_SPAM` will
381+
be used. If this happens, Synapse will not call any of the subsequent implementations of
382+
this callback.
383+
384+
*Note:* This will not be called when a user registers.
385+
386+
351387
## Example
352388

353389
The example below is a module that implements the spam checker callback

synapse/http/site.py

+11
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,11 @@ def get_client_ip_if_available(self) -> str:
521521
else:
522522
return self.getClientAddress().host
523523

524+
def request_info(self) -> "RequestInfo":
525+
h = self.getHeader(b"User-Agent")
526+
user_agent = h.decode("ascii", "replace") if h else None
527+
return RequestInfo(user_agent=user_agent, ip=self.get_client_ip_if_available())
528+
524529

525530
class XForwardedForRequest(SynapseRequest):
526531
"""Request object which honours proxy headers
@@ -661,3 +666,9 @@ def request_factory(channel: HTTPChannel, queued: bool) -> Request:
661666

662667
def log(self, request: SynapseRequest) -> None:
663668
pass
669+
670+
671+
@attr.s(auto_attribs=True, frozen=True, slots=True)
672+
class RequestInfo:
673+
user_agent: Optional[str]
674+
ip: str

synapse/module_api/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
)
8181
from synapse.module_api.callbacks.spamchecker_callbacks import (
8282
CHECK_EVENT_FOR_SPAM_CALLBACK,
83+
CHECK_LOGIN_FOR_SPAM_CALLBACK,
8384
CHECK_MEDIA_FILE_FOR_SPAM_CALLBACK,
8485
CHECK_REGISTRATION_FOR_SPAM_CALLBACK,
8586
CHECK_USERNAME_FOR_SPAM_CALLBACK,
@@ -302,6 +303,7 @@ def register_spam_checker_callbacks(
302303
CHECK_REGISTRATION_FOR_SPAM_CALLBACK
303304
] = None,
304305
check_media_file_for_spam: Optional[CHECK_MEDIA_FILE_FOR_SPAM_CALLBACK] = None,
306+
check_login_for_spam: Optional[CHECK_LOGIN_FOR_SPAM_CALLBACK] = None,
305307
) -> None:
306308
"""Registers callbacks for spam checking capabilities.
307309
@@ -319,6 +321,7 @@ def register_spam_checker_callbacks(
319321
check_username_for_spam=check_username_for_spam,
320322
check_registration_for_spam=check_registration_for_spam,
321323
check_media_file_for_spam=check_media_file_for_spam,
324+
check_login_for_spam=check_login_for_spam,
322325
)
323326

324327
def register_account_validity_callbacks(

synapse/module_api/callbacks/spamchecker_callbacks.py

+80
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,26 @@
196196
]
197197
],
198198
]
199+
CHECK_LOGIN_FOR_SPAM_CALLBACK = Callable[
200+
[
201+
str,
202+
Optional[str],
203+
Optional[str],
204+
Collection[Tuple[Optional[str], str]],
205+
Optional[str],
206+
],
207+
Awaitable[
208+
Union[
209+
Literal["NOT_SPAM"],
210+
Codes,
211+
# Highly experimental, not officially part of the spamchecker API, may
212+
# disappear without warning depending on the results of ongoing
213+
# experiments.
214+
# Use this to return additional information as part of an error.
215+
Tuple[Codes, JsonDict],
216+
]
217+
],
218+
]
199219

200220

201221
def load_legacy_spam_checkers(hs: "synapse.server.HomeServer") -> None:
@@ -315,6 +335,7 @@ def __init__(self, hs: "synapse.server.HomeServer") -> None:
315335
self._check_media_file_for_spam_callbacks: List[
316336
CHECK_MEDIA_FILE_FOR_SPAM_CALLBACK
317337
] = []
338+
self._check_login_for_spam_callbacks: List[CHECK_LOGIN_FOR_SPAM_CALLBACK] = []
318339

319340
def register_callbacks(
320341
self,
@@ -335,6 +356,7 @@ def register_callbacks(
335356
CHECK_REGISTRATION_FOR_SPAM_CALLBACK
336357
] = None,
337358
check_media_file_for_spam: Optional[CHECK_MEDIA_FILE_FOR_SPAM_CALLBACK] = None,
359+
check_login_for_spam: Optional[CHECK_LOGIN_FOR_SPAM_CALLBACK] = None,
338360
) -> None:
339361
"""Register callbacks from module for each hook."""
340362
if check_event_for_spam is not None:
@@ -378,6 +400,9 @@ def register_callbacks(
378400
if check_media_file_for_spam is not None:
379401
self._check_media_file_for_spam_callbacks.append(check_media_file_for_spam)
380402

403+
if check_login_for_spam is not None:
404+
self._check_login_for_spam_callbacks.append(check_login_for_spam)
405+
381406
@trace
382407
async def check_event_for_spam(
383408
self, event: "synapse.events.EventBase"
@@ -819,3 +844,58 @@ async def check_media_file_for_spam(
819844
return synapse.api.errors.Codes.FORBIDDEN, {}
820845

821846
return self.NOT_SPAM
847+
848+
async def check_login_for_spam(
849+
self,
850+
user_id: str,
851+
device_id: Optional[str],
852+
initial_display_name: Optional[str],
853+
request_info: Collection[Tuple[Optional[str], str]],
854+
auth_provider_id: Optional[str] = None,
855+
) -> Union[Tuple[Codes, dict], Literal["NOT_SPAM"]]:
856+
"""Checks if we should allow the given registration request.
857+
858+
Args:
859+
user_id: The request user ID
860+
request_info: List of tuples of user agent and IP that
861+
were used during the registration process.
862+
auth_provider_id: The SSO IdP the user used, e.g "oidc", "saml",
863+
"cas". If any. Note this does not include users registered
864+
via a password provider.
865+
866+
Returns:
867+
Enum for how the request should be handled
868+
"""
869+
870+
for callback in self._check_login_for_spam_callbacks:
871+
with Measure(
872+
self.clock, "{}.{}".format(callback.__module__, callback.__qualname__)
873+
):
874+
res = await delay_cancellation(
875+
callback(
876+
user_id,
877+
device_id,
878+
initial_display_name,
879+
request_info,
880+
auth_provider_id,
881+
)
882+
)
883+
# Normalize return values to `Codes` or `"NOT_SPAM"`.
884+
if res is self.NOT_SPAM:
885+
continue
886+
elif isinstance(res, synapse.api.errors.Codes):
887+
return res, {}
888+
elif (
889+
isinstance(res, tuple)
890+
and len(res) == 2
891+
and isinstance(res[0], synapse.api.errors.Codes)
892+
and isinstance(res[1], dict)
893+
):
894+
return res
895+
else:
896+
logger.warning(
897+
"Module returned invalid value, rejecting login as spam"
898+
)
899+
return synapse.api.errors.Codes.FORBIDDEN, {}
900+
901+
return self.NOT_SPAM

synapse/rest/client/login.py

+48-4
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
parse_json_object_from_request,
5151
parse_string,
5252
)
53-
from synapse.http.site import SynapseRequest
53+
from synapse.http.site import RequestInfo, SynapseRequest
5454
from synapse.rest.client._base import client_patterns
5555
from synapse.rest.well_known import WellKnownBuilder
5656
from synapse.types import JsonDict, UserID
@@ -114,6 +114,7 @@ def __init__(self, hs: "HomeServer"):
114114
self.auth_handler = self.hs.get_auth_handler()
115115
self.registration_handler = hs.get_registration_handler()
116116
self._sso_handler = hs.get_sso_handler()
117+
self._spam_checker = hs.get_module_api_callbacks().spam_checker
117118

118119
self._well_known_builder = WellKnownBuilder(hs)
119120
self._address_ratelimiter = Ratelimiter(
@@ -197,6 +198,8 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, LoginResponse]:
197198
self._refresh_tokens_enabled and client_requested_refresh_token
198199
)
199200

201+
request_info = request.request_info()
202+
200203
try:
201204
if login_submission["type"] == LoginRestServlet.APPSERVICE_TYPE:
202205
requester = await self.auth.get_user_by_req(request)
@@ -216,6 +219,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, LoginResponse]:
216219
login_submission,
217220
appservice,
218221
should_issue_refresh_token=should_issue_refresh_token,
222+
request_info=request_info,
219223
)
220224
elif (
221225
self.jwt_enabled
@@ -227,6 +231,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, LoginResponse]:
227231
result = await self._do_jwt_login(
228232
login_submission,
229233
should_issue_refresh_token=should_issue_refresh_token,
234+
request_info=request_info,
230235
)
231236
elif login_submission["type"] == LoginRestServlet.TOKEN_TYPE:
232237
await self._address_ratelimiter.ratelimit(
@@ -235,6 +240,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, LoginResponse]:
235240
result = await self._do_token_login(
236241
login_submission,
237242
should_issue_refresh_token=should_issue_refresh_token,
243+
request_info=request_info,
238244
)
239245
else:
240246
await self._address_ratelimiter.ratelimit(
@@ -243,6 +249,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, LoginResponse]:
243249
result = await self._do_other_login(
244250
login_submission,
245251
should_issue_refresh_token=should_issue_refresh_token,
252+
request_info=request_info,
246253
)
247254
except KeyError:
248255
raise SynapseError(400, "Missing JSON keys.")
@@ -265,6 +272,8 @@ async def _do_appservice_login(
265272
login_submission: JsonDict,
266273
appservice: ApplicationService,
267274
should_issue_refresh_token: bool = False,
275+
*,
276+
request_info: RequestInfo,
268277
) -> LoginResponse:
269278
identifier = login_submission.get("identifier")
270279
logger.info("Got appservice login request with identifier: %r", identifier)
@@ -300,10 +309,15 @@ async def _do_appservice_login(
300309
# The user represented by an appservice's configured sender_localpart
301310
# is not actually created in Synapse.
302311
should_check_deactivated=qualified_user_id != appservice.sender,
312+
request_info=request_info,
303313
)
304314

305315
async def _do_other_login(
306-
self, login_submission: JsonDict, should_issue_refresh_token: bool = False
316+
self,
317+
login_submission: JsonDict,
318+
should_issue_refresh_token: bool = False,
319+
*,
320+
request_info: RequestInfo,
307321
) -> LoginResponse:
308322
"""Handle non-token/saml/jwt logins
309323
@@ -333,6 +347,7 @@ async def _do_other_login(
333347
login_submission,
334348
callback,
335349
should_issue_refresh_token=should_issue_refresh_token,
350+
request_info=request_info,
336351
)
337352
return result
338353

@@ -347,6 +362,8 @@ async def _complete_login(
347362
should_issue_refresh_token: bool = False,
348363
auth_provider_session_id: Optional[str] = None,
349364
should_check_deactivated: bool = True,
365+
*,
366+
request_info: RequestInfo,
350367
) -> LoginResponse:
351368
"""Called when we've successfully authed the user and now need to
352369
actually login them in (e.g. create devices). This gets called on
@@ -371,6 +388,7 @@ async def _complete_login(
371388
372389
This exists purely for appservice's configured sender_localpart
373390
which doesn't have an associated user in the database.
391+
request_info: The user agent/IP address of the user.
374392
375393
Returns:
376394
Dictionary of account information after successful login.
@@ -417,6 +435,22 @@ async def _complete_login(
417435
)
418436

419437
initial_display_name = login_submission.get("initial_device_display_name")
438+
spam_check = await self._spam_checker.check_login_for_spam(
439+
user_id,
440+
device_id=device_id,
441+
initial_display_name=initial_display_name,
442+
request_info=[(request_info.user_agent, request_info.ip)],
443+
auth_provider_id=auth_provider_id,
444+
)
445+
if spam_check != self._spam_checker.NOT_SPAM:
446+
logger.info("Blocking login due to spam checker")
447+
raise SynapseError(
448+
403,
449+
msg="Login was blocked by the server",
450+
errcode=spam_check[0],
451+
additional_fields=spam_check[1],
452+
)
453+
420454
(
421455
device_id,
422456
access_token,
@@ -451,7 +485,11 @@ async def _complete_login(
451485
return result
452486

453487
async def _do_token_login(
454-
self, login_submission: JsonDict, should_issue_refresh_token: bool = False
488+
self,
489+
login_submission: JsonDict,
490+
should_issue_refresh_token: bool = False,
491+
*,
492+
request_info: RequestInfo,
455493
) -> LoginResponse:
456494
"""
457495
Handle token login.
@@ -474,10 +512,15 @@ async def _do_token_login(
474512
auth_provider_id=res.auth_provider_id,
475513
should_issue_refresh_token=should_issue_refresh_token,
476514
auth_provider_session_id=res.auth_provider_session_id,
515+
request_info=request_info,
477516
)
478517

479518
async def _do_jwt_login(
480-
self, login_submission: JsonDict, should_issue_refresh_token: bool = False
519+
self,
520+
login_submission: JsonDict,
521+
should_issue_refresh_token: bool = False,
522+
*,
523+
request_info: RequestInfo,
481524
) -> LoginResponse:
482525
"""
483526
Handle the custom JWT login.
@@ -496,6 +539,7 @@ async def _do_jwt_login(
496539
login_submission,
497540
create_non_existent_users=True,
498541
should_issue_refresh_token=should_issue_refresh_token,
542+
request_info=request_info,
499543
)
500544

501545

0 commit comments

Comments
 (0)