Merge commit '18ab35284' into anoa/dinsic_release_1_31_0
This commit is contained in:
1
changelog.d/9258.feature
Normal file
1
changelog.d/9258.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add ratelimits to invites in rooms and to specific users.
|
||||
1
changelog.d/9265.bugfix
Normal file
1
changelog.d/9265.bugfix
Normal file
@@ -0,0 +1 @@
|
||||
Prevent password hashes from getting dropped if a client failed threepid validation during a User Interactive Auth stage. Removes a workaround for an ancient bug in Riot Web <v0.7.4.
|
||||
1
changelog.d/9270.misc
Normal file
1
changelog.d/9270.misc
Normal file
@@ -0,0 +1 @@
|
||||
Restore PyPy compatibility by not calling CPython-specific GC methods when under PyPy.
|
||||
1
changelog.d/9272.feature
Normal file
1
changelog.d/9272.feature
Normal file
@@ -0,0 +1 @@
|
||||
Improve the user experience of setting up an account via single-sign on.
|
||||
1
changelog.d/9275.feature
Normal file
1
changelog.d/9275.feature
Normal file
@@ -0,0 +1 @@
|
||||
Improve the user experience of setting up an account via single-sign on.
|
||||
1
changelog.d/9283.feature
Normal file
1
changelog.d/9283.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add phone home stats for encrypted messages.
|
||||
@@ -895,6 +895,8 @@ log_config: "CONFDIR/SERVERNAME.log.config"
|
||||
# "remote" for when users are trying to join rooms not on the server (which
|
||||
# can be more expensive)
|
||||
# - one for ratelimiting how often a user or IP can attempt to validate a 3PID.
|
||||
# - two for ratelimiting how often invites can be sent in a room or to a
|
||||
# specific user.
|
||||
#
|
||||
# The defaults are as shown below.
|
||||
#
|
||||
@@ -936,6 +938,14 @@ log_config: "CONFDIR/SERVERNAME.log.config"
|
||||
#rc_3pid_validation:
|
||||
# per_second: 0.003
|
||||
# burst_count: 5
|
||||
#
|
||||
#rc_invites:
|
||||
# per_room:
|
||||
# per_second: 0.3
|
||||
# burst_count: 10
|
||||
# per_user:
|
||||
# per_second: 0.003
|
||||
# burst_count: 5
|
||||
|
||||
# Ratelimiting settings for incoming federation
|
||||
#
|
||||
@@ -1984,7 +1994,8 @@ saml2_config:
|
||||
#
|
||||
# localpart_template: Jinja2 template for the localpart of the MXID.
|
||||
# If this is not set, the user will be prompted to choose their
|
||||
# own username.
|
||||
# own username (see 'sso_auth_account_details.html' in the 'sso'
|
||||
# section of this file).
|
||||
#
|
||||
# display_name_template: Jinja2 template for the display name to set
|
||||
# on first login. If unset, no displayname will be set.
|
||||
@@ -2147,10 +2158,40 @@ sso:
|
||||
#
|
||||
# * idp: the 'idp_id' of the chosen IDP.
|
||||
#
|
||||
# * HTML page to prompt new users to enter a userid and confirm other
|
||||
# details: 'sso_auth_account_details.html'. This is only shown if the
|
||||
# SSO implementation (with any user_mapping_provider) does not return
|
||||
# a localpart.
|
||||
#
|
||||
# When rendering, this template is given the following variables:
|
||||
#
|
||||
# * server_name: the homeserver's name.
|
||||
#
|
||||
# * idp: details of the SSO Identity Provider that the user logged in
|
||||
# with: an object with the following attributes:
|
||||
#
|
||||
# * idp_id: unique identifier for the IdP
|
||||
# * idp_name: user-facing name for the IdP
|
||||
# * idp_icon: if specified in the IdP config, an MXC URI for an icon
|
||||
# for the IdP
|
||||
# * idp_brand: if specified in the IdP config, a textual identifier
|
||||
# for the brand of the IdP
|
||||
#
|
||||
# * user_attributes: an object containing details about the user that
|
||||
# we received from the IdP. May have the following attributes:
|
||||
#
|
||||
# * display_name: the user's display_name
|
||||
# * emails: a list of email addresses
|
||||
#
|
||||
# The template should render a form which submits the following fields:
|
||||
#
|
||||
# * username: the localpart of the user's chosen user id
|
||||
#
|
||||
# * HTML page for a confirmation step before redirecting back to the client
|
||||
# with the login token: 'sso_redirect_confirm.html'.
|
||||
#
|
||||
# When rendering, this template is given three variables:
|
||||
# When rendering, this template is given the following variables:
|
||||
#
|
||||
# * redirect_url: the URL the user is about to be redirected to. Needs
|
||||
# manual escaping (see
|
||||
# https://jinja.palletsprojects.com/en/2.11.x/templates/#html-escaping).
|
||||
@@ -2163,6 +2204,17 @@ sso:
|
||||
#
|
||||
# * server_name: the homeserver's name.
|
||||
#
|
||||
# * new_user: a boolean indicating whether this is the user's first time
|
||||
# logging in.
|
||||
#
|
||||
# * user_id: the user's matrix ID.
|
||||
#
|
||||
# * user_profile.avatar_url: an MXC URI for the user's avatar, if any.
|
||||
# None if the user has not set an avatar.
|
||||
#
|
||||
# * user_profile.display_name: the user's display name. None if the user
|
||||
# has not set a display name.
|
||||
#
|
||||
# * HTML page which notifies the user that they are authenticating to confirm
|
||||
# an operation on their account during the user interactive authentication
|
||||
# process: 'sso_auth_confirm.html'.
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import gc
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
@@ -339,7 +340,7 @@ async def start(hs: "synapse.server.HomeServer", listeners: Iterable[ListenerCon
|
||||
# rest of time. Doing so means less work each GC (hopefully).
|
||||
#
|
||||
# This only works on Python 3.7
|
||||
if sys.version_info >= (3, 7):
|
||||
if platform.python_implementation() == "CPython" and sys.version_info >= (3, 7):
|
||||
gc.collect()
|
||||
gc.freeze()
|
||||
|
||||
|
||||
@@ -93,15 +93,20 @@ async def phone_stats_home(hs, stats, stats_process=_stats_process):
|
||||
|
||||
stats["daily_active_users"] = await hs.get_datastore().count_daily_users()
|
||||
stats["monthly_active_users"] = await hs.get_datastore().count_monthly_users()
|
||||
daily_active_e2ee_rooms = await hs.get_datastore().count_daily_active_e2ee_rooms()
|
||||
stats["daily_active_e2ee_rooms"] = daily_active_e2ee_rooms
|
||||
stats["daily_e2ee_messages"] = await hs.get_datastore().count_daily_e2ee_messages()
|
||||
daily_sent_e2ee_messages = await hs.get_datastore().count_daily_sent_e2ee_messages()
|
||||
stats["daily_sent_e2ee_messages"] = daily_sent_e2ee_messages
|
||||
stats["daily_active_rooms"] = await hs.get_datastore().count_daily_active_rooms()
|
||||
stats["daily_messages"] = await hs.get_datastore().count_daily_messages()
|
||||
daily_sent_messages = await hs.get_datastore().count_daily_sent_messages()
|
||||
stats["daily_sent_messages"] = daily_sent_messages
|
||||
|
||||
r30_results = await hs.get_datastore().count_r30_users()
|
||||
for name, count in r30_results.items():
|
||||
stats["r30_users_" + name] = count
|
||||
|
||||
daily_sent_messages = await hs.get_datastore().count_daily_sent_messages()
|
||||
stats["daily_sent_messages"] = daily_sent_messages
|
||||
stats["cache_factor"] = hs.config.caches.global_factor
|
||||
stats["event_cache_size"] = hs.config.caches.event_cache_size
|
||||
|
||||
|
||||
@@ -18,19 +18,19 @@
|
||||
import argparse
|
||||
import errno
|
||||
import os
|
||||
import time
|
||||
import urllib.parse
|
||||
from collections import OrderedDict
|
||||
from hashlib import sha256
|
||||
from io import open as io_open
|
||||
from textwrap import dedent
|
||||
from typing import Any, Callable, Iterable, List, MutableMapping, Optional
|
||||
from typing import Any, Iterable, List, MutableMapping, Optional
|
||||
|
||||
import attr
|
||||
import jinja2
|
||||
import pkg_resources
|
||||
import yaml
|
||||
|
||||
from synapse.util.templates import _create_mxc_to_http_filter, _format_ts_filter
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""Represents a problem parsing the configuration
|
||||
@@ -263,6 +263,7 @@ class Config:
|
||||
# Search the custom template directory as well
|
||||
search_directories.insert(0, custom_template_directory)
|
||||
|
||||
# TODO: switch to synapse.util.templates.build_jinja_env
|
||||
loader = jinja2.FileSystemLoader(search_directories)
|
||||
env = jinja2.Environment(loader=loader, autoescape=jinja2.select_autoescape(),)
|
||||
|
||||
@@ -278,38 +279,6 @@ class Config:
|
||||
return [env.get_template(filename) for filename in filenames]
|
||||
|
||||
|
||||
def _format_ts_filter(value: int, format: str):
|
||||
return time.strftime(format, time.localtime(value / 1000))
|
||||
|
||||
|
||||
def _create_mxc_to_http_filter(public_baseurl: str) -> Callable:
|
||||
"""Create and return a jinja2 filter that converts MXC urls to HTTP
|
||||
|
||||
Args:
|
||||
public_baseurl: The public, accessible base URL of the homeserver
|
||||
"""
|
||||
|
||||
def mxc_to_http_filter(value, width, height, resize_method="crop"):
|
||||
if value[0:6] != "mxc://":
|
||||
return ""
|
||||
|
||||
server_and_media_id = value[6:]
|
||||
fragment = None
|
||||
if "#" in server_and_media_id:
|
||||
server_and_media_id, fragment = server_and_media_id.split("#", 1)
|
||||
fragment = "#" + fragment
|
||||
|
||||
params = {"width": width, "height": height, "method": resize_method}
|
||||
return "%s_matrix/media/v1/thumbnail/%s?%s%s" % (
|
||||
public_baseurl,
|
||||
server_and_media_id,
|
||||
urllib.parse.urlencode(params),
|
||||
fragment or "",
|
||||
)
|
||||
|
||||
return mxc_to_http_filter
|
||||
|
||||
|
||||
class RootConfig:
|
||||
"""
|
||||
Holder of an application's configuration.
|
||||
|
||||
@@ -151,7 +151,8 @@ class OIDCConfig(Config):
|
||||
#
|
||||
# localpart_template: Jinja2 template for the localpart of the MXID.
|
||||
# If this is not set, the user will be prompted to choose their
|
||||
# own username.
|
||||
# own username (see 'sso_auth_account_details.html' in the 'sso'
|
||||
# section of this file).
|
||||
#
|
||||
# display_name_template: Jinja2 template for the display name to set
|
||||
# on first login. If unset, no displayname will be set.
|
||||
|
||||
@@ -110,6 +110,15 @@ class RatelimitConfig(Config):
|
||||
defaults={"per_second": 0.003, "burst_count": 5},
|
||||
)
|
||||
|
||||
self.rc_invites_per_room = RateLimitConfig(
|
||||
config.get("rc_invites", {}).get("per_room", {}),
|
||||
defaults={"per_second": 0.3, "burst_count": 10},
|
||||
)
|
||||
self.rc_invites_per_user = RateLimitConfig(
|
||||
config.get("rc_invites", {}).get("per_user", {}),
|
||||
defaults={"per_second": 0.003, "burst_count": 5},
|
||||
)
|
||||
|
||||
def generate_config_section(self, **kwargs):
|
||||
return """\
|
||||
## Ratelimiting ##
|
||||
@@ -142,6 +151,8 @@ class RatelimitConfig(Config):
|
||||
# "remote" for when users are trying to join rooms not on the server (which
|
||||
# can be more expensive)
|
||||
# - one for ratelimiting how often a user or IP can attempt to validate a 3PID.
|
||||
# - two for ratelimiting how often invites can be sent in a room or to a
|
||||
# specific user.
|
||||
#
|
||||
# The defaults are as shown below.
|
||||
#
|
||||
@@ -183,6 +194,14 @@ class RatelimitConfig(Config):
|
||||
#rc_3pid_validation:
|
||||
# per_second: 0.003
|
||||
# burst_count: 5
|
||||
#
|
||||
#rc_invites:
|
||||
# per_room:
|
||||
# per_second: 0.3
|
||||
# burst_count: 10
|
||||
# per_user:
|
||||
# per_second: 0.003
|
||||
# burst_count: 5
|
||||
|
||||
# Ratelimiting settings for incoming federation
|
||||
#
|
||||
|
||||
@@ -27,7 +27,7 @@ class SSOConfig(Config):
|
||||
sso_config = config.get("sso") or {} # type: Dict[str, Any]
|
||||
|
||||
# The sso-specific template_dir
|
||||
template_dir = sso_config.get("template_dir")
|
||||
self.sso_template_dir = sso_config.get("template_dir")
|
||||
|
||||
# Read templates from disk
|
||||
(
|
||||
@@ -48,7 +48,7 @@ class SSOConfig(Config):
|
||||
"sso_auth_success.html",
|
||||
"sso_auth_bad_user.html",
|
||||
],
|
||||
template_dir,
|
||||
self.sso_template_dir,
|
||||
)
|
||||
|
||||
# These templates have no placeholders, so render them here
|
||||
@@ -124,10 +124,40 @@ class SSOConfig(Config):
|
||||
#
|
||||
# * idp: the 'idp_id' of the chosen IDP.
|
||||
#
|
||||
# * HTML page to prompt new users to enter a userid and confirm other
|
||||
# details: 'sso_auth_account_details.html'. This is only shown if the
|
||||
# SSO implementation (with any user_mapping_provider) does not return
|
||||
# a localpart.
|
||||
#
|
||||
# When rendering, this template is given the following variables:
|
||||
#
|
||||
# * server_name: the homeserver's name.
|
||||
#
|
||||
# * idp: details of the SSO Identity Provider that the user logged in
|
||||
# with: an object with the following attributes:
|
||||
#
|
||||
# * idp_id: unique identifier for the IdP
|
||||
# * idp_name: user-facing name for the IdP
|
||||
# * idp_icon: if specified in the IdP config, an MXC URI for an icon
|
||||
# for the IdP
|
||||
# * idp_brand: if specified in the IdP config, a textual identifier
|
||||
# for the brand of the IdP
|
||||
#
|
||||
# * user_attributes: an object containing details about the user that
|
||||
# we received from the IdP. May have the following attributes:
|
||||
#
|
||||
# * display_name: the user's display_name
|
||||
# * emails: a list of email addresses
|
||||
#
|
||||
# The template should render a form which submits the following fields:
|
||||
#
|
||||
# * username: the localpart of the user's chosen user id
|
||||
#
|
||||
# * HTML page for a confirmation step before redirecting back to the client
|
||||
# with the login token: 'sso_redirect_confirm.html'.
|
||||
#
|
||||
# When rendering, this template is given three variables:
|
||||
# When rendering, this template is given the following variables:
|
||||
#
|
||||
# * redirect_url: the URL the user is about to be redirected to. Needs
|
||||
# manual escaping (see
|
||||
# https://jinja.palletsprojects.com/en/2.11.x/templates/#html-escaping).
|
||||
@@ -140,6 +170,17 @@ class SSOConfig(Config):
|
||||
#
|
||||
# * server_name: the homeserver's name.
|
||||
#
|
||||
# * new_user: a boolean indicating whether this is the user's first time
|
||||
# logging in.
|
||||
#
|
||||
# * user_id: the user's matrix ID.
|
||||
#
|
||||
# * user_profile.avatar_url: an MXC URI for the user's avatar, if any.
|
||||
# None if the user has not set an avatar.
|
||||
#
|
||||
# * user_profile.display_name: the user's display name. None if the user
|
||||
# has not set a display name.
|
||||
#
|
||||
# * HTML page which notifies the user that they are authenticating to confirm
|
||||
# an operation on their account during the user interactive authentication
|
||||
# process: 'sso_auth_confirm.html'.
|
||||
|
||||
@@ -812,7 +812,7 @@ class FederationClient(FederationBase):
|
||||
"User's homeserver does not support this room version",
|
||||
Codes.UNSUPPORTED_ROOM_VERSION,
|
||||
)
|
||||
elif e.code == 403:
|
||||
elif e.code in (403, 429):
|
||||
raise e.to_synapse_error()
|
||||
else:
|
||||
raise
|
||||
|
||||
@@ -61,6 +61,7 @@ from synapse.http.site import SynapseRequest
|
||||
from synapse.logging.context import defer_to_thread
|
||||
from synapse.metrics.background_process_metrics import run_as_background_process
|
||||
from synapse.module_api import ModuleApi
|
||||
from synapse.storage.roommember import ProfileInfo
|
||||
from synapse.types import JsonDict, Requester, UserID
|
||||
from synapse.util import stringutils as stringutils
|
||||
from synapse.util.async_helpers import maybe_awaitable
|
||||
@@ -1386,6 +1387,7 @@ class AuthHandler(BaseHandler):
|
||||
request: Request,
|
||||
client_redirect_url: str,
|
||||
extra_attributes: Optional[JsonDict] = None,
|
||||
new_user: bool = False,
|
||||
):
|
||||
"""Having figured out a mxid for this user, complete the HTTP request
|
||||
|
||||
@@ -1396,6 +1398,8 @@ class AuthHandler(BaseHandler):
|
||||
process.
|
||||
extra_attributes: Extra attributes which will be passed to the client
|
||||
during successful login. Must be JSON serializable.
|
||||
new_user: True if we should use wording appropriate to a user who has just
|
||||
registered.
|
||||
"""
|
||||
# If the account has been deactivated, do not proceed with the login
|
||||
# flow.
|
||||
@@ -1404,8 +1408,17 @@ class AuthHandler(BaseHandler):
|
||||
respond_with_html(request, 403, self._sso_account_deactivated_template)
|
||||
return
|
||||
|
||||
profile = await self.store.get_profileinfo(
|
||||
UserID.from_string(registered_user_id).localpart
|
||||
)
|
||||
|
||||
self._complete_sso_login(
|
||||
registered_user_id, request, client_redirect_url, extra_attributes
|
||||
registered_user_id,
|
||||
request,
|
||||
client_redirect_url,
|
||||
extra_attributes,
|
||||
new_user=new_user,
|
||||
user_profile_data=profile,
|
||||
)
|
||||
|
||||
def _complete_sso_login(
|
||||
@@ -1414,12 +1427,18 @@ class AuthHandler(BaseHandler):
|
||||
request: Request,
|
||||
client_redirect_url: str,
|
||||
extra_attributes: Optional[JsonDict] = None,
|
||||
new_user: bool = False,
|
||||
user_profile_data: Optional[ProfileInfo] = None,
|
||||
):
|
||||
"""
|
||||
The synchronous portion of complete_sso_login.
|
||||
|
||||
This exists purely for backwards compatibility of synapse.module_api.ModuleApi.
|
||||
"""
|
||||
|
||||
if user_profile_data is None:
|
||||
user_profile_data = ProfileInfo(None, None)
|
||||
|
||||
# Store any extra attributes which will be passed in the login response.
|
||||
# Note that this is per-user so it may overwrite a previous value, this
|
||||
# is considered OK since the newest SSO attributes should be most valid.
|
||||
@@ -1457,6 +1476,9 @@ class AuthHandler(BaseHandler):
|
||||
display_url=redirect_url_no_params,
|
||||
redirect_url=redirect_url,
|
||||
server_name=self._server_name,
|
||||
new_user=new_user,
|
||||
user_id=registered_user_id,
|
||||
user_profile=user_profile_data,
|
||||
)
|
||||
respond_with_html(request, 200, html)
|
||||
|
||||
|
||||
@@ -1695,6 +1695,10 @@ class FederationHandler(BaseHandler):
|
||||
if event.state_key == self._server_notices_mxid:
|
||||
raise SynapseError(HTTPStatus.FORBIDDEN, "Cannot invite this user")
|
||||
|
||||
# We retrieve the room member handler here as to not cause a cyclic dependency
|
||||
member_handler = self.hs.get_room_member_handler()
|
||||
member_handler.ratelimit_invite(event.room_id, event.state_key)
|
||||
|
||||
# keep a record of the room version, if we don't yet know it.
|
||||
# (this may get overwritten if we later get a different room version in a
|
||||
# join dance).
|
||||
|
||||
@@ -126,6 +126,10 @@ class RoomCreationHandler(BaseHandler):
|
||||
|
||||
self.third_party_event_rules = hs.get_third_party_event_rules()
|
||||
|
||||
self._invite_burst_count = (
|
||||
hs.config.ratelimiting.rc_invites_per_room.burst_count
|
||||
)
|
||||
|
||||
async def upgrade_room(
|
||||
self, requester: Requester, old_room_id: str, new_version: RoomVersion
|
||||
) -> str:
|
||||
@@ -680,6 +684,9 @@ class RoomCreationHandler(BaseHandler):
|
||||
invite_3pid_list = []
|
||||
invite_list = []
|
||||
|
||||
if len(invite_list) + len(invite_3pid_list) > self._invite_burst_count:
|
||||
raise SynapseError(400, "Cannot invite so many users at once")
|
||||
|
||||
await self.event_creation_handler.assert_accepted_privacy_policy(requester)
|
||||
|
||||
power_level_content_override = config.get("power_level_content_override")
|
||||
|
||||
@@ -93,6 +93,17 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
|
||||
burst_count=hs.config.ratelimiting.rc_joins_remote.burst_count,
|
||||
)
|
||||
|
||||
self._invites_per_room_limiter = Ratelimiter(
|
||||
clock=self.clock,
|
||||
rate_hz=hs.config.ratelimiting.rc_invites_per_room.per_second,
|
||||
burst_count=hs.config.ratelimiting.rc_invites_per_room.burst_count,
|
||||
)
|
||||
self._invites_per_user_limiter = Ratelimiter(
|
||||
clock=self.clock,
|
||||
rate_hz=hs.config.ratelimiting.rc_invites_per_user.per_second,
|
||||
burst_count=hs.config.ratelimiting.rc_invites_per_user.burst_count,
|
||||
)
|
||||
|
||||
# This is only used to get at ratelimit function, and
|
||||
# maybe_kick_guest_users. It's fine there are multiple of these as
|
||||
# it doesn't store state.
|
||||
@@ -187,6 +198,12 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def ratelimit_invite(self, room_id: str, invitee_user_id: str):
|
||||
"""Ratelimit invites by room and by target user.
|
||||
"""
|
||||
self._invites_per_room_limiter.ratelimit(room_id)
|
||||
self._invites_per_user_limiter.ratelimit(invitee_user_id)
|
||||
|
||||
async def _local_membership_update(
|
||||
self,
|
||||
requester: Requester,
|
||||
@@ -433,8 +450,12 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
|
||||
raise SynapseError(403, "This room has been blocked on this server")
|
||||
|
||||
if effective_membership_state == Membership.INVITE:
|
||||
target_id = target.to_string()
|
||||
if ratelimit:
|
||||
self.ratelimit_invite(room_id, target_id)
|
||||
|
||||
# block any attempts to invite the server notices mxid
|
||||
if target.to_string() == self._server_notices_mxid:
|
||||
if target_id == self._server_notices_mxid:
|
||||
raise SynapseError(HTTPStatus.FORBIDDEN, "Cannot invite this user")
|
||||
|
||||
block_invite = False
|
||||
@@ -461,7 +482,7 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
|
||||
|
||||
if not await self.spam_checker.user_may_invite(
|
||||
requester.user.to_string(),
|
||||
target.to_string(),
|
||||
target_id,
|
||||
third_party_invite=None,
|
||||
room_id=room_id,
|
||||
new_room=new_room,
|
||||
|
||||
@@ -391,6 +391,8 @@ class SsoHandler:
|
||||
to an additional page. (e.g. to prompt for more information)
|
||||
|
||||
"""
|
||||
new_user = False
|
||||
|
||||
# grab a lock while we try to find a mapping for this user. This seems...
|
||||
# optimistic, especially for implementations that end up redirecting to
|
||||
# interstitial pages.
|
||||
@@ -431,9 +433,14 @@ class SsoHandler:
|
||||
get_request_user_agent(request),
|
||||
request.getClientIP(),
|
||||
)
|
||||
new_user = True
|
||||
|
||||
await self._auth_handler.complete_sso_login(
|
||||
user_id, request, client_redirect_url, extra_login_attributes
|
||||
user_id,
|
||||
request,
|
||||
client_redirect_url,
|
||||
extra_login_attributes,
|
||||
new_user=new_user,
|
||||
)
|
||||
|
||||
async def _call_attribute_mapper(
|
||||
@@ -523,7 +530,7 @@ class SsoHandler:
|
||||
logger.info("Recorded registration session id %s", session_id)
|
||||
|
||||
# Set the cookie and redirect to the username picker
|
||||
e = RedirectException(b"/_synapse/client/pick_username")
|
||||
e = RedirectException(b"/_synapse/client/pick_username/account_details")
|
||||
e.cookies.append(
|
||||
b"%s=%s; path=/"
|
||||
% (USERNAME_MAPPING_SESSION_COOKIE_NAME, session_id.encode("ascii"))
|
||||
@@ -778,6 +785,7 @@ class SsoHandler:
|
||||
request,
|
||||
session.client_redirect_url,
|
||||
session.extra_login_attributes,
|
||||
new_user=True,
|
||||
)
|
||||
|
||||
def _expire_old_sessions(self):
|
||||
|
||||
@@ -279,7 +279,11 @@ class ModuleApi:
|
||||
)
|
||||
|
||||
async def complete_sso_login_async(
|
||||
self, registered_user_id: str, request: SynapseRequest, client_redirect_url: str
|
||||
self,
|
||||
registered_user_id: str,
|
||||
request: SynapseRequest,
|
||||
client_redirect_url: str,
|
||||
new_user: bool = False,
|
||||
):
|
||||
"""Complete a SSO login by redirecting the user to a page to confirm whether they
|
||||
want their access token sent to `client_redirect_url`, or redirect them to that
|
||||
@@ -291,9 +295,11 @@ class ModuleApi:
|
||||
request: The request to respond to.
|
||||
client_redirect_url: The URL to which to offer to redirect the user (or to
|
||||
redirect them directly if whitelisted).
|
||||
new_user: set to true to use wording for the consent appropriate to a user
|
||||
who has just registered.
|
||||
"""
|
||||
await self._auth_handler.complete_sso_login(
|
||||
registered_user_id, request, client_redirect_url,
|
||||
registered_user_id, request, client_redirect_url, new_user=new_user
|
||||
)
|
||||
|
||||
@defer.inlineCallbacks
|
||||
|
||||
83
synapse/res/templates/sso.css
Normal file
83
synapse/res/templates/sso.css
Normal file
@@ -0,0 +1,83 @@
|
||||
body {
|
||||
font-family: "Inter", "Helvetica", "Arial", sans-serif;
|
||||
font-size: 14px;
|
||||
color: #17191C;
|
||||
}
|
||||
|
||||
header {
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
margin: 24px auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
header p {
|
||||
color: #737D8C;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
h2 img {
|
||||
vertical-align: middle;
|
||||
margin-right: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
margin: 24px auto;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
padding: 12px;
|
||||
color: white;
|
||||
background-color: #418DED;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.profile .avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 100%;
|
||||
display: block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.profile .display-name {
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.profile .user-id {
|
||||
color: #737D8C;
|
||||
}
|
||||
|
||||
.profile .display-name, .profile .user-id {
|
||||
line-height: 18px;
|
||||
}
|
||||
115
synapse/res/templates/sso_auth_account_details.html
Normal file
115
synapse/res/templates/sso_auth_account_details.html
Normal file
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Synapse Login</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||
<style type="text/css">
|
||||
{% include "sso.css" without context %}
|
||||
|
||||
.username_input {
|
||||
display: flex;
|
||||
border: 2px solid #418DED;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
margin: 16px 0;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.username_input label {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 14px;
|
||||
font-size: 80%;
|
||||
background: white;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.username_input input {
|
||||
flex: 1;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.username_input div {
|
||||
color: #8D99A5;
|
||||
}
|
||||
|
||||
.idp-pick-details {
|
||||
border: 1px solid #E9ECF1;
|
||||
border-radius: 8px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.idp-pick-details h2 {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.idp-pick-details .idp-detail {
|
||||
border-top: 1px solid #E9ECF1;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.idp-pick-details .use, .idp-pick-details .idp-value {
|
||||
color: #737D8C;
|
||||
}
|
||||
|
||||
.idp-pick-details .idp-value {
|
||||
margin: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.idp-pick-details .avatar {
|
||||
width: 53px;
|
||||
height: 53px;
|
||||
border-radius: 100%;
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Your account is nearly ready</h1>
|
||||
<p>Check your details before creating an account on {{ server_name }}</p>
|
||||
</header>
|
||||
<main>
|
||||
<form method="post" class="form__input" id="form">
|
||||
<div class="username_input">
|
||||
<label for="field-username">Username</label>
|
||||
<div class="prefix">@</div>
|
||||
<input type="text" name="username" id="field-username" autofocus required pattern="[a-z0-9\-=_\/\.]+">
|
||||
<div class="postfix">:{{ server_name }}</div>
|
||||
</div>
|
||||
<input type="submit" value="Continue" class="primary-button">
|
||||
{% if user_attributes %}
|
||||
<section class="idp-pick-details">
|
||||
<h2><img src="{{ idp.idp_icon | mxc_to_http(24, 24) }}"/>Information from {{ idp.idp_name }}</h2>
|
||||
{% if user_attributes.avatar_url %}
|
||||
<div class="idp-detail idp-avatar">
|
||||
<img src="{{ user_attributes.avatar_url }}" class="avatar" />
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if user_attributes.display_name %}
|
||||
<div class="idp-detail">
|
||||
<p class="idp-value">{{ user_attributes.display_name }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for email in user_attributes.emails %}
|
||||
<div class="idp-detail">
|
||||
<p class="idp-value">{{ email }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</form>
|
||||
</main>
|
||||
<script type="text/javascript">
|
||||
{% include "sso_auth_account_details.js" without context %}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
76
synapse/res/templates/sso_auth_account_details.js
Normal file
76
synapse/res/templates/sso_auth_account_details.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const usernameField = document.getElementById("field-username");
|
||||
|
||||
function throttle(fn, wait) {
|
||||
let timeout;
|
||||
return function() {
|
||||
const args = Array.from(arguments);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(fn.bind.apply(fn, [null].concat(args)), wait);
|
||||
}
|
||||
}
|
||||
|
||||
function checkUsernameAvailable(username) {
|
||||
let check_uri = 'check?username=' + encodeURIComponent(username);
|
||||
return fetch(check_uri, {
|
||||
// include the cookie
|
||||
"credentials": "same-origin",
|
||||
}).then((response) => {
|
||||
if(!response.ok) {
|
||||
// for non-200 responses, raise the body of the response as an exception
|
||||
return response.text().then((text) => { throw new Error(text); });
|
||||
} else {
|
||||
return response.json();
|
||||
}
|
||||
}).then((json) => {
|
||||
if(json.error) {
|
||||
return {message: json.error};
|
||||
} else if(json.available) {
|
||||
return {available: true};
|
||||
} else {
|
||||
return {message: username + " is not available, please choose another."};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateUsername(username) {
|
||||
usernameField.setCustomValidity("");
|
||||
if (usernameField.validity.valueMissing) {
|
||||
usernameField.setCustomValidity("Please provide a username");
|
||||
return;
|
||||
}
|
||||
if (usernameField.validity.patternMismatch) {
|
||||
usernameField.setCustomValidity("Invalid username, please only use " + allowedCharactersString);
|
||||
return;
|
||||
}
|
||||
usernameField.setCustomValidity("Checking if username is available …");
|
||||
throttledCheckUsernameAvailable(username);
|
||||
}
|
||||
|
||||
const throttledCheckUsernameAvailable = throttle(function(username) {
|
||||
const handleError = function(err) {
|
||||
// don't prevent form submission on error
|
||||
usernameField.setCustomValidity("");
|
||||
console.log(err.message);
|
||||
};
|
||||
try {
|
||||
checkUsernameAvailable(username).then(function(result) {
|
||||
if (!result.available) {
|
||||
usernameField.setCustomValidity(result.message);
|
||||
usernameField.reportValidity();
|
||||
} else {
|
||||
usernameField.setCustomValidity("");
|
||||
}
|
||||
}, handleError);
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
usernameField.addEventListener("input", function(evt) {
|
||||
validateUsername(usernameField.value);
|
||||
});
|
||||
usernameField.addEventListener("change", function(evt) {
|
||||
validateUsername(usernameField.value);
|
||||
});
|
||||
@@ -3,12 +3,34 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>SSO redirect confirmation</title>
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||
<style type="text/css">
|
||||
{% include "sso.css" without context %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>The application at <span style="font-weight:bold">{{ display_url }}</span> is requesting full access to your <span style="font-weight:bold">{{ server_name }}</span> Matrix account.</p>
|
||||
<p>If you don't recognise this address, you should ignore this and close this tab.</p>
|
||||
<p>
|
||||
<a href="{{ redirect_url }}">I trust this address</a>
|
||||
</p>
|
||||
<header>
|
||||
{% if new_user %}
|
||||
<h1>Your account is now ready</h1>
|
||||
<p>You've made your account on {{ server_name }}.</p>
|
||||
{% else %}
|
||||
<h1>Log in</h1>
|
||||
{% endif %}
|
||||
<p>Continue to confirm you trust <strong>{{ display_url }}</strong>.</p>
|
||||
</header>
|
||||
<main>
|
||||
{% if user_profile.avatar_url %}
|
||||
<div class="profile">
|
||||
<img src="{{ user_profile.avatar_url | mxc_to_http(64, 64) }}" class="avatar" />
|
||||
<div class="profile-details">
|
||||
{% if user_profile.display_name %}
|
||||
<div class="display-name">{{ user_profile.display_name }}</div>
|
||||
{% endif %}
|
||||
<div class="user-id">{{ user_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<a href="{{ redirect_url }}" class="primary-button">Continue</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Synapse Login</title>
|
||||
<link rel="stylesheet" href="style.css" type="text/css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<form method="post" class="form__input" id="form" action="submit">
|
||||
<label for="field-username">Please pick your username:</label>
|
||||
<input type="text" name="username" id="field-username" autofocus="">
|
||||
<input type="submit" class="button button--full-width" id="button-submit" value="Submit">
|
||||
</form>
|
||||
<!-- this is used for feedback -->
|
||||
<div role=alert class="tooltip hidden" id="message"></div>
|
||||
<script src="script.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,95 +0,0 @@
|
||||
let inputField = document.getElementById("field-username");
|
||||
let inputForm = document.getElementById("form");
|
||||
let submitButton = document.getElementById("button-submit");
|
||||
let message = document.getElementById("message");
|
||||
|
||||
// Submit username and receive response
|
||||
function showMessage(messageText) {
|
||||
// Unhide the message text
|
||||
message.classList.remove("hidden");
|
||||
|
||||
message.textContent = messageText;
|
||||
};
|
||||
|
||||
function doSubmit() {
|
||||
showMessage("Success. Please wait a moment for your browser to redirect.");
|
||||
|
||||
// remove the event handler before re-submitting the form.
|
||||
delete inputForm.onsubmit;
|
||||
inputForm.submit();
|
||||
}
|
||||
|
||||
function onResponse(response) {
|
||||
// Display message
|
||||
showMessage(response);
|
||||
|
||||
// Enable submit button and input field
|
||||
submitButton.classList.remove('button--disabled');
|
||||
submitButton.value = "Submit";
|
||||
};
|
||||
|
||||
let allowedUsernameCharacters = RegExp("[^a-z0-9\\.\\_\\=\\-\\/]");
|
||||
function usernameIsValid(username) {
|
||||
return !allowedUsernameCharacters.test(username);
|
||||
}
|
||||
let allowedCharactersString = "lowercase letters, digits, ., _, -, /, =";
|
||||
|
||||
function buildQueryString(params) {
|
||||
return Object.keys(params)
|
||||
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
|
||||
.join('&');
|
||||
}
|
||||
|
||||
function submitUsername(username) {
|
||||
if(username.length == 0) {
|
||||
onResponse("Please enter a username.");
|
||||
return;
|
||||
}
|
||||
if(!usernameIsValid(username)) {
|
||||
onResponse("Invalid username. Only the following characters are allowed: " + allowedCharactersString);
|
||||
return;
|
||||
}
|
||||
|
||||
// if this browser doesn't support fetch, skip the availability check.
|
||||
if(!window.fetch) {
|
||||
doSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
let check_uri = 'check?' + buildQueryString({"username": username});
|
||||
fetch(check_uri, {
|
||||
// include the cookie
|
||||
"credentials": "same-origin",
|
||||
}).then((response) => {
|
||||
if(!response.ok) {
|
||||
// for non-200 responses, raise the body of the response as an exception
|
||||
return response.text().then((text) => { throw text; });
|
||||
} else {
|
||||
return response.json();
|
||||
}
|
||||
}).then((json) => {
|
||||
if(json.error) {
|
||||
throw json.error;
|
||||
} else if(json.available) {
|
||||
doSubmit();
|
||||
} else {
|
||||
onResponse("This username is not available, please choose another.");
|
||||
}
|
||||
}).catch((err) => {
|
||||
onResponse("Error checking username availability: " + err);
|
||||
});
|
||||
}
|
||||
|
||||
function clickSubmit() {
|
||||
event.preventDefault();
|
||||
if(submitButton.classList.contains('button--disabled')) { return; }
|
||||
|
||||
// Disable submit button and input field
|
||||
submitButton.classList.add('button--disabled');
|
||||
|
||||
// Submit username
|
||||
submitButton.value = "Checking...";
|
||||
submitUsername(inputField.value);
|
||||
};
|
||||
|
||||
inputForm.onsubmit = clickSubmit;
|
||||
@@ -1,27 +0,0 @@
|
||||
input[type="text"] {
|
||||
font-size: 100%;
|
||||
background-color: #ededf0;
|
||||
border: 1px solid #fff;
|
||||
border-radius: .2em;
|
||||
padding: .5em .9em;
|
||||
display: block;
|
||||
width: 26em;
|
||||
}
|
||||
|
||||
.button--disabled {
|
||||
border-color: #fff;
|
||||
background-color: transparent;
|
||||
color: #000;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
background-color: #f9f9fa;
|
||||
padding: 1em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ class ConsentResource(DirectServeHtmlResource):
|
||||
|
||||
consent_template_directory = hs.config.user_consent_template_dir
|
||||
|
||||
# TODO: switch to synapse.util.templates.build_jinja_env
|
||||
loader = jinja2.FileSystemLoader(consent_template_directory)
|
||||
self._jinja_env = jinja2.Environment(
|
||||
loader=loader, autoescape=jinja2.select_autoescape(["html", "htm", "xml"])
|
||||
|
||||
@@ -13,41 +13,41 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pkg_resources
|
||||
|
||||
from twisted.web.http import Request
|
||||
from twisted.web.resource import Resource
|
||||
from twisted.web.static import File
|
||||
|
||||
from synapse.api.errors import SynapseError
|
||||
from synapse.handlers.sso import get_username_mapping_session_cookie_from_request
|
||||
from synapse.http.server import DirectServeHtmlResource, DirectServeJsonResource
|
||||
from synapse.http.server import (
|
||||
DirectServeHtmlResource,
|
||||
DirectServeJsonResource,
|
||||
respond_with_html,
|
||||
)
|
||||
from synapse.http.servlet import parse_string
|
||||
from synapse.http.site import SynapseRequest
|
||||
from synapse.util.templates import build_jinja_env
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from synapse.server import HomeServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def pick_username_resource(hs: "HomeServer") -> Resource:
|
||||
"""Factory method to generate the username picker resource.
|
||||
|
||||
This resource gets mounted under /_synapse/client/pick_username. The top-level
|
||||
resource is just a File resource which serves up the static files in the resources
|
||||
"res" directory, but it has a couple of children:
|
||||
This resource gets mounted under /_synapse/client/pick_username and has two
|
||||
children:
|
||||
|
||||
* "submit", which does the mechanics of registering the new user, and redirects the
|
||||
browser back to the client URL
|
||||
|
||||
* "check": checks if a userid is free.
|
||||
* "account_details": renders the form and handles the POSTed response
|
||||
* "check": a JSON endpoint which checks if a userid is free.
|
||||
"""
|
||||
|
||||
# XXX should we make this path customisable so that admins can restyle it?
|
||||
base_path = pkg_resources.resource_filename("synapse", "res/username_picker")
|
||||
|
||||
res = File(base_path)
|
||||
res.putChild(b"submit", SubmitResource(hs))
|
||||
res = Resource()
|
||||
res.putChild(b"account_details", AccountDetailsResource(hs))
|
||||
res.putChild(b"check", AvailabilityCheckResource(hs))
|
||||
|
||||
return res
|
||||
@@ -69,15 +69,54 @@ class AvailabilityCheckResource(DirectServeJsonResource):
|
||||
return 200, {"available": is_available}
|
||||
|
||||
|
||||
class SubmitResource(DirectServeHtmlResource):
|
||||
class AccountDetailsResource(DirectServeHtmlResource):
|
||||
def __init__(self, hs: "HomeServer"):
|
||||
super().__init__()
|
||||
self._sso_handler = hs.get_sso_handler()
|
||||
|
||||
async def _async_render_POST(self, request: SynapseRequest):
|
||||
localpart = parse_string(request, "username", required=True)
|
||||
def template_search_dirs():
|
||||
if hs.config.sso.sso_template_dir:
|
||||
yield hs.config.sso.sso_template_dir
|
||||
yield hs.config.sso.default_template_dir
|
||||
|
||||
session_id = get_username_mapping_session_cookie_from_request(request)
|
||||
self._jinja_env = build_jinja_env(template_search_dirs(), hs.config)
|
||||
|
||||
async def _async_render_GET(self, request: Request) -> None:
|
||||
try:
|
||||
session_id = get_username_mapping_session_cookie_from_request(request)
|
||||
session = self._sso_handler.get_mapping_session(session_id)
|
||||
except SynapseError as e:
|
||||
logger.warning("Error fetching session: %s", e)
|
||||
self._sso_handler.render_error(request, "bad_session", e.msg, code=e.code)
|
||||
return
|
||||
|
||||
idp_id = session.auth_provider_id
|
||||
template_params = {
|
||||
"idp": self._sso_handler.get_identity_providers()[idp_id],
|
||||
"user_attributes": {
|
||||
"display_name": session.display_name,
|
||||
"emails": session.emails,
|
||||
},
|
||||
}
|
||||
|
||||
template = self._jinja_env.get_template("sso_auth_account_details.html")
|
||||
html = template.render(template_params)
|
||||
respond_with_html(request, 200, html)
|
||||
|
||||
async def _async_render_POST(self, request: SynapseRequest):
|
||||
try:
|
||||
session_id = get_username_mapping_session_cookie_from_request(request)
|
||||
except SynapseError as e:
|
||||
logger.warning("Error fetching session cookie: %s", e)
|
||||
self._sso_handler.render_error(request, "bad_session", e.msg, code=e.code)
|
||||
return
|
||||
|
||||
try:
|
||||
localpart = parse_string(request, "username", required=True)
|
||||
except SynapseError as e:
|
||||
logger.warning("[session %s] bad param: %s", session_id, e)
|
||||
self._sso_handler.render_error(request, "bad_param", e.msg, code=e.code)
|
||||
return
|
||||
|
||||
await self._sso_handler.handle_submit_username_request(
|
||||
request, localpart, session_id
|
||||
|
||||
@@ -88,6 +88,62 @@ class ServerMetricsStore(EventPushActionsWorkerStore, SQLBaseStore):
|
||||
(x[0] - 1) * x[1] for x in res if x[1]
|
||||
)
|
||||
|
||||
async def count_daily_e2ee_messages(self):
|
||||
"""
|
||||
Returns an estimate of the number of messages sent in the last day.
|
||||
|
||||
If it has been significantly less or more than one day since the last
|
||||
call to this function, it will return None.
|
||||
"""
|
||||
|
||||
def _count_messages(txn):
|
||||
sql = """
|
||||
SELECT COALESCE(COUNT(*), 0) FROM events
|
||||
WHERE type = 'm.room.encrypted'
|
||||
AND stream_ordering > ?
|
||||
"""
|
||||
txn.execute(sql, (self.stream_ordering_day_ago,))
|
||||
(count,) = txn.fetchone()
|
||||
return count
|
||||
|
||||
return await self.db_pool.runInteraction("count_e2ee_messages", _count_messages)
|
||||
|
||||
async def count_daily_sent_e2ee_messages(self):
|
||||
def _count_messages(txn):
|
||||
# This is good enough as if you have silly characters in your own
|
||||
# hostname then thats your own fault.
|
||||
like_clause = "%:" + self.hs.hostname
|
||||
|
||||
sql = """
|
||||
SELECT COALESCE(COUNT(*), 0) FROM events
|
||||
WHERE type = 'm.room.encrypted'
|
||||
AND sender LIKE ?
|
||||
AND stream_ordering > ?
|
||||
"""
|
||||
|
||||
txn.execute(sql, (like_clause, self.stream_ordering_day_ago))
|
||||
(count,) = txn.fetchone()
|
||||
return count
|
||||
|
||||
return await self.db_pool.runInteraction(
|
||||
"count_daily_sent_e2ee_messages", _count_messages
|
||||
)
|
||||
|
||||
async def count_daily_active_e2ee_rooms(self):
|
||||
def _count(txn):
|
||||
sql = """
|
||||
SELECT COALESCE(COUNT(DISTINCT room_id), 0) FROM events
|
||||
WHERE type = 'm.room.encrypted'
|
||||
AND stream_ordering > ?
|
||||
"""
|
||||
txn.execute(sql, (self.stream_ordering_day_ago,))
|
||||
(count,) = txn.fetchone()
|
||||
return count
|
||||
|
||||
return await self.db_pool.runInteraction(
|
||||
"count_daily_active_e2ee_rooms", _count
|
||||
)
|
||||
|
||||
async def count_daily_messages(self):
|
||||
"""
|
||||
Returns an estimate of the number of messages sent in the last day.
|
||||
|
||||
106
synapse/util/templates.py
Normal file
106
synapse/util/templates.py
Normal file
@@ -0,0 +1,106 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Utilities for dealing with jinja2 templates"""
|
||||
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import TYPE_CHECKING, Callable, Iterable, Union
|
||||
|
||||
import jinja2
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from synapse.config.homeserver import HomeServerConfig
|
||||
|
||||
|
||||
def build_jinja_env(
|
||||
template_search_directories: Iterable[str],
|
||||
config: "HomeServerConfig",
|
||||
autoescape: Union[bool, Callable[[str], bool], None] = None,
|
||||
) -> jinja2.Environment:
|
||||
"""Set up a Jinja2 environment to load templates from the given search path
|
||||
|
||||
The returned environment defines the following filters:
|
||||
- format_ts: formats timestamps as strings in the server's local timezone
|
||||
(XXX: why is that useful??)
|
||||
- mxc_to_http: converts mxc: uris to http URIs. Args are:
|
||||
(uri, width, height, resize_method="crop")
|
||||
|
||||
and the following global variables:
|
||||
- server_name: matrix server name
|
||||
|
||||
Args:
|
||||
template_search_directories: directories to search for templates
|
||||
|
||||
config: homeserver config, for things like `server_name` and `public_baseurl`
|
||||
|
||||
autoescape: whether template variables should be autoescaped. bool, or
|
||||
a function mapping from template name to bool. Defaults to escaping templates
|
||||
whose names end in .html, .xml or .htm.
|
||||
|
||||
Returns:
|
||||
jinja environment
|
||||
"""
|
||||
|
||||
if autoescape is None:
|
||||
autoescape = jinja2.select_autoescape()
|
||||
|
||||
loader = jinja2.FileSystemLoader(template_search_directories)
|
||||
env = jinja2.Environment(loader=loader, autoescape=autoescape)
|
||||
|
||||
# Update the environment with our custom filters
|
||||
env.filters.update(
|
||||
{
|
||||
"format_ts": _format_ts_filter,
|
||||
"mxc_to_http": _create_mxc_to_http_filter(config.public_baseurl),
|
||||
}
|
||||
)
|
||||
|
||||
# common variables for all templates
|
||||
env.globals.update({"server_name": config.server_name})
|
||||
|
||||
return env
|
||||
|
||||
|
||||
def _create_mxc_to_http_filter(public_baseurl: str) -> Callable:
|
||||
"""Create and return a jinja2 filter that converts MXC urls to HTTP
|
||||
|
||||
Args:
|
||||
public_baseurl: The public, accessible base URL of the homeserver
|
||||
"""
|
||||
|
||||
def mxc_to_http_filter(value, width, height, resize_method="crop"):
|
||||
if value[0:6] != "mxc://":
|
||||
return ""
|
||||
|
||||
server_and_media_id = value[6:]
|
||||
fragment = None
|
||||
if "#" in server_and_media_id:
|
||||
server_and_media_id, fragment = server_and_media_id.split("#", 1)
|
||||
fragment = "#" + fragment
|
||||
|
||||
params = {"width": width, "height": height, "method": resize_method}
|
||||
return "%s_matrix/media/v1/thumbnail/%s?%s%s" % (
|
||||
public_baseurl,
|
||||
server_and_media_id,
|
||||
urllib.parse.urlencode(params),
|
||||
fragment or "",
|
||||
)
|
||||
|
||||
return mxc_to_http_filter
|
||||
|
||||
|
||||
def _format_ts_filter(value: int, format: str):
|
||||
return time.strftime(format, time.localtime(value / 1000))
|
||||
@@ -62,7 +62,7 @@ class CasHandlerTestCase(HomeserverTestCase):
|
||||
|
||||
# check that the auth handler got called as expected
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
"@test_user:test", request, "redirect_uri", None
|
||||
"@test_user:test", request, "redirect_uri", None, new_user=True
|
||||
)
|
||||
|
||||
def test_map_cas_user_to_existing_user(self):
|
||||
@@ -85,7 +85,7 @@ class CasHandlerTestCase(HomeserverTestCase):
|
||||
|
||||
# check that the auth handler got called as expected
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
"@test_user:test", request, "redirect_uri", None
|
||||
"@test_user:test", request, "redirect_uri", None, new_user=False
|
||||
)
|
||||
|
||||
# Subsequent calls should map to the same mxid.
|
||||
@@ -94,7 +94,7 @@ class CasHandlerTestCase(HomeserverTestCase):
|
||||
self.handler._handle_cas_response(request, cas_response, "redirect_uri", "")
|
||||
)
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
"@test_user:test", request, "redirect_uri", None
|
||||
"@test_user:test", request, "redirect_uri", None, new_user=False
|
||||
)
|
||||
|
||||
def test_map_cas_user_to_invalid_localpart(self):
|
||||
@@ -112,7 +112,7 @@ class CasHandlerTestCase(HomeserverTestCase):
|
||||
|
||||
# check that the auth handler got called as expected
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
"@f=c3=b6=c3=b6:test", request, "redirect_uri", None
|
||||
"@f=c3=b6=c3=b6:test", request, "redirect_uri", None, new_user=True
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import logging
|
||||
from unittest import TestCase
|
||||
|
||||
from synapse.api.constants import EventTypes
|
||||
from synapse.api.errors import AuthError, Codes, SynapseError
|
||||
from synapse.api.errors import AuthError, Codes, LimitExceededError, SynapseError
|
||||
from synapse.api.room_versions import RoomVersions
|
||||
from synapse.events import EventBase
|
||||
from synapse.federation.federation_base import event_from_pdu_json
|
||||
@@ -191,6 +191,97 @@ class FederationTestCase(unittest.HomeserverTestCase):
|
||||
|
||||
self.assertEqual(sg, sg2)
|
||||
|
||||
@unittest.override_config(
|
||||
{"rc_invites": {"per_room": {"per_second": 0.5, "burst_count": 3}}}
|
||||
)
|
||||
def test_invite_by_room_ratelimit(self):
|
||||
"""Tests that invites from federation in a room are actually rate-limited.
|
||||
"""
|
||||
other_server = "otherserver"
|
||||
other_user = "@otheruser:" + other_server
|
||||
|
||||
# create the room
|
||||
user_id = self.register_user("kermit", "test")
|
||||
tok = self.login("kermit", "test")
|
||||
room_id = self.helper.create_room_as(room_creator=user_id, tok=tok)
|
||||
room_version = self.get_success(self.store.get_room_version(room_id))
|
||||
|
||||
def create_invite_for(local_user):
|
||||
return event_from_pdu_json(
|
||||
{
|
||||
"type": EventTypes.Member,
|
||||
"content": {"membership": "invite"},
|
||||
"room_id": room_id,
|
||||
"sender": other_user,
|
||||
"state_key": local_user,
|
||||
"depth": 32,
|
||||
"prev_events": [],
|
||||
"auth_events": [],
|
||||
"origin_server_ts": self.clock.time_msec(),
|
||||
},
|
||||
room_version,
|
||||
)
|
||||
|
||||
for i in range(3):
|
||||
self.get_success(
|
||||
self.handler.on_invite_request(
|
||||
other_server,
|
||||
create_invite_for("@user-%d:test" % (i,)),
|
||||
room_version,
|
||||
)
|
||||
)
|
||||
|
||||
self.get_failure(
|
||||
self.handler.on_invite_request(
|
||||
other_server, create_invite_for("@user-4:test"), room_version,
|
||||
),
|
||||
exc=LimitExceededError,
|
||||
)
|
||||
|
||||
@unittest.override_config(
|
||||
{"rc_invites": {"per_user": {"per_second": 0.5, "burst_count": 3}}}
|
||||
)
|
||||
def test_invite_by_user_ratelimit(self):
|
||||
"""Tests that invites from federation to a particular user are
|
||||
actually rate-limited.
|
||||
"""
|
||||
other_server = "otherserver"
|
||||
other_user = "@otheruser:" + other_server
|
||||
|
||||
# create the room
|
||||
user_id = self.register_user("kermit", "test")
|
||||
tok = self.login("kermit", "test")
|
||||
|
||||
def create_invite():
|
||||
room_id = self.helper.create_room_as(room_creator=user_id, tok=tok)
|
||||
room_version = self.get_success(self.store.get_room_version(room_id))
|
||||
return event_from_pdu_json(
|
||||
{
|
||||
"type": EventTypes.Member,
|
||||
"content": {"membership": "invite"},
|
||||
"room_id": room_id,
|
||||
"sender": other_user,
|
||||
"state_key": "@user:test",
|
||||
"depth": 32,
|
||||
"prev_events": [],
|
||||
"auth_events": [],
|
||||
"origin_server_ts": self.clock.time_msec(),
|
||||
},
|
||||
room_version,
|
||||
)
|
||||
|
||||
for i in range(3):
|
||||
event = create_invite()
|
||||
self.get_success(
|
||||
self.handler.on_invite_request(other_server, event, event.room_version,)
|
||||
)
|
||||
|
||||
event = create_invite()
|
||||
self.get_failure(
|
||||
self.handler.on_invite_request(other_server, event, event.room_version,),
|
||||
exc=LimitExceededError,
|
||||
)
|
||||
|
||||
def _build_and_send_join_event(self, other_server, other_user, room_id):
|
||||
join_event = self.get_success(
|
||||
self.handler.on_make_join_request(other_server, room_id, other_user)
|
||||
|
||||
@@ -419,7 +419,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||
self.get_success(self.handler.handle_oidc_callback(request))
|
||||
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
expected_user_id, request, client_redirect_url, None,
|
||||
expected_user_id, request, client_redirect_url, None, new_user=True
|
||||
)
|
||||
self.provider._exchange_code.assert_called_once_with(code)
|
||||
self.provider._parse_id_token.assert_called_once_with(token, nonce=nonce)
|
||||
@@ -450,7 +450,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||
self.get_success(self.handler.handle_oidc_callback(request))
|
||||
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
expected_user_id, request, client_redirect_url, None,
|
||||
expected_user_id, request, client_redirect_url, None, new_user=False
|
||||
)
|
||||
self.provider._exchange_code.assert_called_once_with(code)
|
||||
self.provider._parse_id_token.assert_not_called()
|
||||
@@ -623,7 +623,11 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||
self.get_success(self.handler.handle_oidc_callback(request))
|
||||
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
"@foo:test", request, client_redirect_url, {"phone": "1234567"},
|
||||
"@foo:test",
|
||||
request,
|
||||
client_redirect_url,
|
||||
{"phone": "1234567"},
|
||||
new_user=True,
|
||||
)
|
||||
|
||||
def test_map_userinfo_to_user(self):
|
||||
@@ -637,7 +641,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||
}
|
||||
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
"@test_user:test", ANY, ANY, None,
|
||||
"@test_user:test", ANY, ANY, None, new_user=True
|
||||
)
|
||||
auth_handler.complete_sso_login.reset_mock()
|
||||
|
||||
@@ -648,7 +652,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||
}
|
||||
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
"@test_user_2:test", ANY, ANY, None,
|
||||
"@test_user_2:test", ANY, ANY, None, new_user=True
|
||||
)
|
||||
auth_handler.complete_sso_login.reset_mock()
|
||||
|
||||
@@ -685,14 +689,14 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||
}
|
||||
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
user.to_string(), ANY, ANY, None,
|
||||
user.to_string(), ANY, ANY, None, new_user=False
|
||||
)
|
||||
auth_handler.complete_sso_login.reset_mock()
|
||||
|
||||
# Subsequent calls should map to the same mxid.
|
||||
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
user.to_string(), ANY, ANY, None,
|
||||
user.to_string(), ANY, ANY, None, new_user=False
|
||||
)
|
||||
auth_handler.complete_sso_login.reset_mock()
|
||||
|
||||
@@ -707,7 +711,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||
}
|
||||
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
user.to_string(), ANY, ANY, None,
|
||||
user.to_string(), ANY, ANY, None, new_user=False
|
||||
)
|
||||
auth_handler.complete_sso_login.reset_mock()
|
||||
|
||||
@@ -743,7 +747,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||
|
||||
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
"@TEST_USER_2:test", ANY, ANY, None,
|
||||
"@TEST_USER_2:test", ANY, ANY, None, new_user=False
|
||||
)
|
||||
|
||||
def test_map_userinfo_to_invalid_localpart(self):
|
||||
@@ -779,7 +783,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||
|
||||
# test_user is already taken, so test_user1 gets registered instead.
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
"@test_user1:test", ANY, ANY, None,
|
||||
"@test_user1:test", ANY, ANY, None, new_user=True
|
||||
)
|
||||
auth_handler.complete_sso_login.reset_mock()
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ class SamlHandlerTestCase(HomeserverTestCase):
|
||||
|
||||
# check that the auth handler got called as expected
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
"@test_user:test", request, "redirect_uri", None
|
||||
"@test_user:test", request, "redirect_uri", None, new_user=True
|
||||
)
|
||||
|
||||
@override_config({"saml2_config": {"grandfathered_mxid_source_attribute": "mxid"}})
|
||||
@@ -157,7 +157,7 @@ class SamlHandlerTestCase(HomeserverTestCase):
|
||||
|
||||
# check that the auth handler got called as expected
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
"@test_user:test", request, "", None
|
||||
"@test_user:test", request, "", None, new_user=False
|
||||
)
|
||||
|
||||
# Subsequent calls should map to the same mxid.
|
||||
@@ -166,7 +166,7 @@ class SamlHandlerTestCase(HomeserverTestCase):
|
||||
self.handler._handle_authn_response(request, saml_response, "")
|
||||
)
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
"@test_user:test", request, "", None
|
||||
"@test_user:test", request, "", None, new_user=False
|
||||
)
|
||||
|
||||
def test_map_saml_response_to_invalid_localpart(self):
|
||||
@@ -214,7 +214,7 @@ class SamlHandlerTestCase(HomeserverTestCase):
|
||||
|
||||
# test_user is already taken, so test_user1 gets registered instead.
|
||||
auth_handler.complete_sso_login.assert_called_once_with(
|
||||
"@test_user1:test", request, "", None
|
||||
"@test_user1:test", request, "", None, new_user=True
|
||||
)
|
||||
auth_handler.complete_sso_login.reset_mock()
|
||||
|
||||
|
||||
@@ -1222,7 +1222,7 @@ class UsernamePickerTestCase(HomeserverTestCase):
|
||||
# that should redirect to the username picker
|
||||
self.assertEqual(channel.code, 302, channel.result)
|
||||
picker_url = channel.headers.getRawHeaders("Location")[0]
|
||||
self.assertEqual(picker_url, "/_synapse/client/pick_username")
|
||||
self.assertEqual(picker_url, "/_synapse/client/pick_username/account_details")
|
||||
|
||||
# ... with a username_mapping_session cookie
|
||||
cookies = {} # type: Dict[str,str]
|
||||
@@ -1247,11 +1247,10 @@ class UsernamePickerTestCase(HomeserverTestCase):
|
||||
|
||||
# Now, submit a username to the username picker, which should serve a redirect
|
||||
# to the completion page
|
||||
submit_path = picker_url + "/submit"
|
||||
content = urlencode({b"username": b"bobby"}).encode("utf8")
|
||||
chan = self.make_request(
|
||||
"POST",
|
||||
path=submit_path,
|
||||
path=picker_url,
|
||||
content=content,
|
||||
content_is_form=True,
|
||||
custom_headers=[
|
||||
|
||||
@@ -616,6 +616,41 @@ class RoomMemberStateTestCase(RoomBase):
|
||||
self.assertEquals(json.loads(content), channel.json_body)
|
||||
|
||||
|
||||
class RoomInviteRatelimitTestCase(RoomBase):
|
||||
user_id = "@sid1:red"
|
||||
|
||||
servlets = [
|
||||
admin.register_servlets,
|
||||
profile.register_servlets,
|
||||
room.register_servlets,
|
||||
]
|
||||
|
||||
@unittest.override_config(
|
||||
{"rc_invites": {"per_room": {"per_second": 0.5, "burst_count": 3}}}
|
||||
)
|
||||
def test_invites_by_rooms_ratelimit(self):
|
||||
"""Tests that invites in a room are actually rate-limited."""
|
||||
room_id = self.helper.create_room_as(self.user_id)
|
||||
|
||||
for i in range(3):
|
||||
self.helper.invite(room_id, self.user_id, "@user-%s:red" % (i,))
|
||||
|
||||
self.helper.invite(room_id, self.user_id, "@user-4:red", expect_code=429)
|
||||
|
||||
@unittest.override_config(
|
||||
{"rc_invites": {"per_user": {"per_second": 0.5, "burst_count": 3}}}
|
||||
)
|
||||
def test_invites_by_users_ratelimit(self):
|
||||
"""Tests that invites to a specific user are actually rate-limited."""
|
||||
|
||||
for i in range(3):
|
||||
room_id = self.helper.create_room_as(self.user_id)
|
||||
self.helper.invite(room_id, self.user_id, "@other-users:red")
|
||||
|
||||
room_id = self.helper.create_room_as(self.user_id)
|
||||
self.helper.invite(room_id, self.user_id, "@other-users:red", expect_code=429)
|
||||
|
||||
|
||||
class RoomJoinRatelimitTestCase(RoomBase):
|
||||
user_id = "@sid1:red"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user