mirror of
https://github.com/element-hq/synapse.git
synced 2024-12-14 11:57:44 +00:00
Allow admins to require a manual approval process before new accounts can be used (using MSC3866) (#13556)
This commit is contained in:
parent
8625ad8099
commit
be76cd8200
21 changed files with 731 additions and 34 deletions
1
changelog.d/13556.feature
Normal file
1
changelog.d/13556.feature
Normal file
|
@ -0,0 +1 @@
|
|||
Allow server admins to require a manual approval process before new accounts can be used (using [MSC3866](https://github.com/matrix-org/matrix-spec-proposals/pull/3866)).
|
|
@ -107,7 +107,7 @@ BOOLEAN_COLUMNS = {
|
|||
"redactions": ["have_censored"],
|
||||
"room_stats_state": ["is_federatable"],
|
||||
"local_media_repository": ["safe_from_quarantine"],
|
||||
"users": ["shadow_banned"],
|
||||
"users": ["shadow_banned", "approved"],
|
||||
"e2e_fallback_keys_json": ["used"],
|
||||
"access_tokens": ["used"],
|
||||
"device_lists_changes_in_room": ["converted_to_destinations"],
|
||||
|
|
|
@ -269,3 +269,14 @@ class PublicRoomsFilterFields:
|
|||
|
||||
GENERIC_SEARCH_TERM: Final = "generic_search_term"
|
||||
ROOM_TYPES: Final = "room_types"
|
||||
|
||||
|
||||
class ApprovalNoticeMedium:
|
||||
"""Identifier for the medium this server will use to serve notice of approval for a
|
||||
specific user's registration.
|
||||
|
||||
As defined in https://github.com/matrix-org/matrix-spec-proposals/blob/babolivier/m_not_approved/proposals/3866-user-not-approved-error.md
|
||||
"""
|
||||
|
||||
NONE = "org.matrix.msc3866.none"
|
||||
EMAIL = "org.matrix.msc3866.email"
|
||||
|
|
|
@ -106,6 +106,8 @@ class Codes(str, Enum):
|
|||
# Part of MSC3895.
|
||||
UNABLE_DUE_TO_PARTIAL_STATE = "ORG.MATRIX.MSC3895_UNABLE_DUE_TO_PARTIAL_STATE"
|
||||
|
||||
USER_AWAITING_APPROVAL = "ORG.MATRIX.MSC3866_USER_AWAITING_APPROVAL"
|
||||
|
||||
|
||||
class CodeMessageException(RuntimeError):
|
||||
"""An exception with integer code and message string attributes.
|
||||
|
@ -566,6 +568,20 @@ class UnredactedContentDeletedError(SynapseError):
|
|||
return cs_error(self.msg, self.errcode, **extra)
|
||||
|
||||
|
||||
class NotApprovedError(SynapseError):
|
||||
def __init__(
|
||||
self,
|
||||
msg: str,
|
||||
approval_notice_medium: str,
|
||||
):
|
||||
super().__init__(
|
||||
code=403,
|
||||
msg=msg,
|
||||
errcode=Codes.USER_AWAITING_APPROVAL,
|
||||
additional_fields={"approval_notice_medium": approval_notice_medium},
|
||||
)
|
||||
|
||||
|
||||
def cs_error(msg: str, code: str = Codes.UNKNOWN, **kwargs: Any) -> "JsonDict":
|
||||
"""Utility method for constructing an error response for client-server
|
||||
interactions.
|
||||
|
|
|
@ -14,10 +14,25 @@
|
|||
|
||||
from typing import Any
|
||||
|
||||
import attr
|
||||
|
||||
from synapse.config._base import Config
|
||||
from synapse.types import JsonDict
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, frozen=True, slots=True)
|
||||
class MSC3866Config:
|
||||
"""Configuration for MSC3866 (mandating approval for new users)"""
|
||||
|
||||
# Whether the base support for the approval process is enabled. This includes the
|
||||
# ability for administrators to check and update the approval of users, even if no
|
||||
# approval is currently required.
|
||||
enabled: bool = False
|
||||
# Whether to require that new users are approved by an admin before their account
|
||||
# can be used. Note that this setting is ignored if 'enabled' is false.
|
||||
require_approval_for_new_accounts: bool = False
|
||||
|
||||
|
||||
class ExperimentalConfig(Config):
|
||||
"""Config section for enabling experimental features"""
|
||||
|
||||
|
@ -97,6 +112,10 @@ class ExperimentalConfig(Config):
|
|||
# MSC3852: Expose last seen user agent field on /_matrix/client/v3/devices.
|
||||
self.msc3852_enabled: bool = experimental.get("msc3852_enabled", False)
|
||||
|
||||
# MSC3866: M_USER_AWAITING_APPROVAL error code
|
||||
raw_msc3866_config = experimental.get("msc3866", {})
|
||||
self.msc3866 = MSC3866Config(**raw_msc3866_config)
|
||||
|
||||
# MSC3881: Remotely toggle push notifications for another client
|
||||
self.msc3881_enabled: bool = experimental.get("msc3881_enabled", False)
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@ class AdminHandler:
|
|||
self.store = hs.get_datastores().main
|
||||
self._storage_controllers = hs.get_storage_controllers()
|
||||
self._state_storage_controller = self._storage_controllers.state
|
||||
self._msc3866_enabled = hs.config.experimental.msc3866.enabled
|
||||
|
||||
async def get_whois(self, user: UserID) -> JsonDict:
|
||||
connections = []
|
||||
|
@ -75,6 +76,10 @@ class AdminHandler:
|
|||
"is_guest",
|
||||
}
|
||||
|
||||
if self._msc3866_enabled:
|
||||
# Only include the approved flag if support for MSC3866 is enabled.
|
||||
user_info_to_return.add("approved")
|
||||
|
||||
# Restrict returned keys to a known set.
|
||||
user_info_dict = {
|
||||
key: value
|
||||
|
|
|
@ -1009,6 +1009,17 @@ class AuthHandler:
|
|||
return res[0]
|
||||
return None
|
||||
|
||||
async def is_user_approved(self, user_id: str) -> bool:
|
||||
"""Checks if a user is approved and therefore can be allowed to log in.
|
||||
|
||||
Args:
|
||||
user_id: the user to check the approval status of.
|
||||
|
||||
Returns:
|
||||
A boolean that is True if the user is approved, False otherwise.
|
||||
"""
|
||||
return await self.store.is_user_approved(user_id)
|
||||
|
||||
async def _find_user_id_and_pwd_hash(
|
||||
self, user_id: str
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
|
|
|
@ -220,6 +220,7 @@ class RegistrationHandler:
|
|||
by_admin: bool = False,
|
||||
user_agent_ips: Optional[List[Tuple[str, str]]] = None,
|
||||
auth_provider_id: Optional[str] = None,
|
||||
approved: bool = False,
|
||||
) -> str:
|
||||
"""Registers a new client on the server.
|
||||
|
||||
|
@ -246,6 +247,8 @@ class RegistrationHandler:
|
|||
user_agent_ips: Tuples of user-agents and IP addresses used
|
||||
during the registration process.
|
||||
auth_provider_id: The SSO IdP the user used, if any.
|
||||
approved: True if the new user should be considered already
|
||||
approved by an administrator.
|
||||
Returns:
|
||||
The registered user_id.
|
||||
Raises:
|
||||
|
@ -307,6 +310,7 @@ class RegistrationHandler:
|
|||
user_type=user_type,
|
||||
address=address,
|
||||
shadow_banned=shadow_banned,
|
||||
approved=approved,
|
||||
)
|
||||
|
||||
profile = await self.store.get_profileinfo(localpart)
|
||||
|
@ -695,6 +699,7 @@ class RegistrationHandler:
|
|||
user_type: Optional[str] = None,
|
||||
address: Optional[str] = None,
|
||||
shadow_banned: bool = False,
|
||||
approved: bool = False,
|
||||
) -> None:
|
||||
"""Register user in the datastore.
|
||||
|
||||
|
@ -713,6 +718,7 @@ class RegistrationHandler:
|
|||
api.constants.UserTypes, or None for a normal user.
|
||||
address: the IP address used to perform the registration.
|
||||
shadow_banned: Whether to shadow-ban the user
|
||||
approved: Whether to mark the user as approved by an administrator
|
||||
"""
|
||||
if self.hs.config.worker.worker_app:
|
||||
await self._register_client(
|
||||
|
@ -726,6 +732,7 @@ class RegistrationHandler:
|
|||
user_type=user_type,
|
||||
address=address,
|
||||
shadow_banned=shadow_banned,
|
||||
approved=approved,
|
||||
)
|
||||
else:
|
||||
await self.store.register_user(
|
||||
|
@ -738,6 +745,7 @@ class RegistrationHandler:
|
|||
admin=admin,
|
||||
user_type=user_type,
|
||||
shadow_banned=shadow_banned,
|
||||
approved=approved,
|
||||
)
|
||||
|
||||
# Only call the account validity module(s) on the main process, to avoid
|
||||
|
|
|
@ -51,6 +51,7 @@ class ReplicationRegisterServlet(ReplicationEndpoint):
|
|||
user_type: Optional[str],
|
||||
address: Optional[str],
|
||||
shadow_banned: bool,
|
||||
approved: bool,
|
||||
) -> JsonDict:
|
||||
"""
|
||||
Args:
|
||||
|
@ -68,6 +69,8 @@ class ReplicationRegisterServlet(ReplicationEndpoint):
|
|||
or None for a normal user.
|
||||
address: the IP address used to perform the regitration.
|
||||
shadow_banned: Whether to shadow-ban the user
|
||||
approved: Whether the user should be considered already approved by an
|
||||
administrator.
|
||||
"""
|
||||
return {
|
||||
"password_hash": password_hash,
|
||||
|
@ -79,6 +82,7 @@ class ReplicationRegisterServlet(ReplicationEndpoint):
|
|||
"user_type": user_type,
|
||||
"address": address,
|
||||
"shadow_banned": shadow_banned,
|
||||
"approved": approved,
|
||||
}
|
||||
|
||||
async def _handle_request( # type: ignore[override]
|
||||
|
@ -99,6 +103,7 @@ class ReplicationRegisterServlet(ReplicationEndpoint):
|
|||
user_type=content["user_type"],
|
||||
address=content["address"],
|
||||
shadow_banned=content["shadow_banned"],
|
||||
approved=content["approved"],
|
||||
)
|
||||
|
||||
return 200, {}
|
||||
|
|
|
@ -69,6 +69,7 @@ class UsersRestServletV2(RestServlet):
|
|||
self.store = hs.get_datastores().main
|
||||
self.auth = hs.get_auth()
|
||||
self.admin_handler = hs.get_admin_handler()
|
||||
self._msc3866_enabled = hs.config.experimental.msc3866.enabled
|
||||
|
||||
async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
|
||||
await assert_requester_is_admin(self.auth, request)
|
||||
|
@ -95,6 +96,13 @@ class UsersRestServletV2(RestServlet):
|
|||
guests = parse_boolean(request, "guests", default=True)
|
||||
deactivated = parse_boolean(request, "deactivated", default=False)
|
||||
|
||||
# If support for MSC3866 is not enabled, apply no filtering based on the
|
||||
# `approved` column.
|
||||
if self._msc3866_enabled:
|
||||
approved = parse_boolean(request, "approved", default=True)
|
||||
else:
|
||||
approved = True
|
||||
|
||||
order_by = parse_string(
|
||||
request,
|
||||
"order_by",
|
||||
|
@ -115,8 +123,22 @@ class UsersRestServletV2(RestServlet):
|
|||
direction = parse_string(request, "dir", default="f", allowed_values=("f", "b"))
|
||||
|
||||
users, total = await self.store.get_users_paginate(
|
||||
start, limit, user_id, name, guests, deactivated, order_by, direction
|
||||
start,
|
||||
limit,
|
||||
user_id,
|
||||
name,
|
||||
guests,
|
||||
deactivated,
|
||||
order_by,
|
||||
direction,
|
||||
approved,
|
||||
)
|
||||
|
||||
# If support for MSC3866 is not enabled, don't show the approval flag.
|
||||
if not self._msc3866_enabled:
|
||||
for user in users:
|
||||
del user["approved"]
|
||||
|
||||
ret = {"users": users, "total": total}
|
||||
if (start + limit) < total:
|
||||
ret["next_token"] = str(start + len(users))
|
||||
|
@ -163,6 +185,7 @@ class UserRestServletV2(RestServlet):
|
|||
self.deactivate_account_handler = hs.get_deactivate_account_handler()
|
||||
self.registration_handler = hs.get_registration_handler()
|
||||
self.pusher_pool = hs.get_pusherpool()
|
||||
self._msc3866_enabled = hs.config.experimental.msc3866.enabled
|
||||
|
||||
async def on_GET(
|
||||
self, request: SynapseRequest, user_id: str
|
||||
|
@ -239,6 +262,15 @@ class UserRestServletV2(RestServlet):
|
|||
HTTPStatus.BAD_REQUEST, "'deactivated' parameter is not of type boolean"
|
||||
)
|
||||
|
||||
approved: Optional[bool] = None
|
||||
if "approved" in body and self._msc3866_enabled:
|
||||
approved = body["approved"]
|
||||
if not isinstance(approved, bool):
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"'approved' parameter is not of type boolean",
|
||||
)
|
||||
|
||||
# convert List[Dict[str, str]] into List[Tuple[str, str]]
|
||||
if external_ids is not None:
|
||||
new_external_ids = [
|
||||
|
@ -343,6 +375,9 @@ class UserRestServletV2(RestServlet):
|
|||
if "user_type" in body:
|
||||
await self.store.set_user_type(target_user, user_type)
|
||||
|
||||
if approved is not None:
|
||||
await self.store.update_user_approval_status(target_user, approved)
|
||||
|
||||
user = await self.admin_handler.get_user(target_user)
|
||||
assert user is not None
|
||||
|
||||
|
@ -355,6 +390,10 @@ class UserRestServletV2(RestServlet):
|
|||
if password is not None:
|
||||
password_hash = await self.auth_handler.hash(password)
|
||||
|
||||
new_user_approved = True
|
||||
if self._msc3866_enabled and approved is not None:
|
||||
new_user_approved = approved
|
||||
|
||||
user_id = await self.registration_handler.register_user(
|
||||
localpart=target_user.localpart,
|
||||
password_hash=password_hash,
|
||||
|
@ -362,6 +401,7 @@ class UserRestServletV2(RestServlet):
|
|||
default_display_name=displayname,
|
||||
user_type=user_type,
|
||||
by_admin=True,
|
||||
approved=new_user_approved,
|
||||
)
|
||||
|
||||
if threepids is not None:
|
||||
|
@ -550,6 +590,7 @@ class UserRegisterServlet(RestServlet):
|
|||
user_type=user_type,
|
||||
default_display_name=displayname,
|
||||
by_admin=True,
|
||||
approved=True,
|
||||
)
|
||||
|
||||
result = await register._create_registration_details(user_id, body)
|
||||
|
|
|
@ -28,7 +28,14 @@ from typing import (
|
|||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from synapse.api.errors import Codes, InvalidClientTokenError, LoginError, SynapseError
|
||||
from synapse.api.constants import ApprovalNoticeMedium
|
||||
from synapse.api.errors import (
|
||||
Codes,
|
||||
InvalidClientTokenError,
|
||||
LoginError,
|
||||
NotApprovedError,
|
||||
SynapseError,
|
||||
)
|
||||
from synapse.api.ratelimiting import Ratelimiter
|
||||
from synapse.api.urls import CLIENT_API_PREFIX
|
||||
from synapse.appservice import ApplicationService
|
||||
|
@ -55,11 +62,11 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
class LoginResponse(TypedDict, total=False):
|
||||
user_id: str
|
||||
access_token: str
|
||||
access_token: Optional[str]
|
||||
home_server: str
|
||||
expires_in_ms: Optional[int]
|
||||
refresh_token: Optional[str]
|
||||
device_id: str
|
||||
device_id: Optional[str]
|
||||
well_known: Optional[Dict[str, Any]]
|
||||
|
||||
|
||||
|
@ -92,6 +99,12 @@ class LoginRestServlet(RestServlet):
|
|||
hs.config.registration.refreshable_access_token_lifetime is not None
|
||||
)
|
||||
|
||||
# Whether we need to check if the user has been approved or not.
|
||||
self._require_approval = (
|
||||
hs.config.experimental.msc3866.enabled
|
||||
and hs.config.experimental.msc3866.require_approval_for_new_accounts
|
||||
)
|
||||
|
||||
self.auth = hs.get_auth()
|
||||
|
||||
self.clock = hs.get_clock()
|
||||
|
@ -220,6 +233,14 @@ class LoginRestServlet(RestServlet):
|
|||
except KeyError:
|
||||
raise SynapseError(400, "Missing JSON keys.")
|
||||
|
||||
if self._require_approval:
|
||||
approved = await self.auth_handler.is_user_approved(result["user_id"])
|
||||
if not approved:
|
||||
raise NotApprovedError(
|
||||
msg="This account is pending approval by a server administrator.",
|
||||
approval_notice_medium=ApprovalNoticeMedium.NONE,
|
||||
)
|
||||
|
||||
well_known_data = self._well_known_builder.get_well_known()
|
||||
if well_known_data:
|
||||
result["well_known"] = well_known_data
|
||||
|
@ -356,6 +377,16 @@ class LoginRestServlet(RestServlet):
|
|||
errcode=Codes.INVALID_PARAM,
|
||||
)
|
||||
|
||||
if self._require_approval:
|
||||
approved = await self.auth_handler.is_user_approved(user_id)
|
||||
if not approved:
|
||||
# If the user isn't approved (and needs to be) we won't allow them to
|
||||
# actually log in, so we don't want to create a device/access token.
|
||||
return LoginResponse(
|
||||
user_id=user_id,
|
||||
home_server=self.hs.hostname,
|
||||
)
|
||||
|
||||
initial_display_name = login_submission.get("initial_device_display_name")
|
||||
(
|
||||
device_id,
|
||||
|
|
|
@ -21,10 +21,15 @@ from twisted.web.server import Request
|
|||
import synapse
|
||||
import synapse.api.auth
|
||||
import synapse.types
|
||||
from synapse.api.constants import APP_SERVICE_REGISTRATION_TYPE, LoginType
|
||||
from synapse.api.constants import (
|
||||
APP_SERVICE_REGISTRATION_TYPE,
|
||||
ApprovalNoticeMedium,
|
||||
LoginType,
|
||||
)
|
||||
from synapse.api.errors import (
|
||||
Codes,
|
||||
InteractiveAuthIncompleteError,
|
||||
NotApprovedError,
|
||||
SynapseError,
|
||||
ThreepidValidationError,
|
||||
UnrecognizedRequestError,
|
||||
|
@ -414,6 +419,11 @@ class RegisterRestServlet(RestServlet):
|
|||
hs.config.registration.inhibit_user_in_use_error
|
||||
)
|
||||
|
||||
self._require_approval = (
|
||||
hs.config.experimental.msc3866.enabled
|
||||
and hs.config.experimental.msc3866.require_approval_for_new_accounts
|
||||
)
|
||||
|
||||
self._registration_flows = _calculate_registration_flows(
|
||||
hs.config, self.auth_handler
|
||||
)
|
||||
|
@ -734,6 +744,12 @@ class RegisterRestServlet(RestServlet):
|
|||
access_token=return_dict.get("access_token"),
|
||||
)
|
||||
|
||||
if self._require_approval:
|
||||
raise NotApprovedError(
|
||||
msg="This account needs to be approved by an administrator before it can be used.",
|
||||
approval_notice_medium=ApprovalNoticeMedium.NONE,
|
||||
)
|
||||
|
||||
return 200, return_dict
|
||||
|
||||
async def _do_appservice_registration(
|
||||
|
@ -778,7 +794,9 @@ class RegisterRestServlet(RestServlet):
|
|||
"user_id": user_id,
|
||||
"home_server": self.hs.hostname,
|
||||
}
|
||||
if not params.get("inhibit_login", False):
|
||||
# We don't want to log the user in if we're going to deny them access because
|
||||
# they need to be approved first.
|
||||
if not params.get("inhibit_login", False) and not self._require_approval:
|
||||
device_id = params.get("device_id")
|
||||
initial_display_name = params.get("initial_device_display_name")
|
||||
(
|
||||
|
|
|
@ -203,6 +203,7 @@ class DataStore(
|
|||
deactivated: bool = False,
|
||||
order_by: str = UserSortOrder.USER_ID.value,
|
||||
direction: str = "f",
|
||||
approved: bool = True,
|
||||
) -> Tuple[List[JsonDict], int]:
|
||||
"""Function to retrieve a paginated list of users from
|
||||
users list. This will return a json list of users and the
|
||||
|
@ -217,6 +218,7 @@ class DataStore(
|
|||
deactivated: whether to include deactivated users
|
||||
order_by: the sort order of the returned list
|
||||
direction: sort ascending or descending
|
||||
approved: whether to include approved users
|
||||
Returns:
|
||||
A tuple of a list of mappings from user to information and a count of total users.
|
||||
"""
|
||||
|
@ -249,6 +251,11 @@ class DataStore(
|
|||
if not deactivated:
|
||||
filters.append("deactivated = 0")
|
||||
|
||||
if not approved:
|
||||
# We ignore NULL values for the approved flag because these should only
|
||||
# be already existing users that we consider as already approved.
|
||||
filters.append("approved IS FALSE")
|
||||
|
||||
where_clause = "WHERE " + " AND ".join(filters) if len(filters) > 0 else ""
|
||||
|
||||
sql_base = f"""
|
||||
|
@ -262,7 +269,7 @@ class DataStore(
|
|||
|
||||
sql = f"""
|
||||
SELECT name, user_type, is_guest, admin, deactivated, shadow_banned,
|
||||
displayname, avatar_url, creation_ts * 1000 as creation_ts
|
||||
displayname, avatar_url, creation_ts * 1000 as creation_ts, approved
|
||||
{sql_base}
|
||||
ORDER BY {order_by_column} {order}, u.name ASC
|
||||
LIMIT ? OFFSET ?
|
||||
|
|
|
@ -166,27 +166,49 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
|
|||
@cached()
|
||||
async def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Deprecated: use get_userinfo_by_id instead"""
|
||||
return await self.db_pool.simple_select_one(
|
||||
table="users",
|
||||
keyvalues={"name": user_id},
|
||||
retcols=[
|
||||
"name",
|
||||
"password_hash",
|
||||
"is_guest",
|
||||
"admin",
|
||||
"consent_version",
|
||||
"consent_ts",
|
||||
"consent_server_notice_sent",
|
||||
"appservice_id",
|
||||
"creation_ts",
|
||||
"user_type",
|
||||
"deactivated",
|
||||
"shadow_banned",
|
||||
],
|
||||
allow_none=True,
|
||||
desc="get_user_by_id",
|
||||
|
||||
def get_user_by_id_txn(txn: LoggingTransaction) -> Optional[Dict[str, Any]]:
|
||||
# We could technically use simple_select_one here, but it would not perform
|
||||
# the COALESCEs (unless hacked into the column names), which could yield
|
||||
# confusing results.
|
||||
txn.execute(
|
||||
"""
|
||||
SELECT
|
||||
name, password_hash, is_guest, admin, consent_version, consent_ts,
|
||||
consent_server_notice_sent, appservice_id, creation_ts, user_type,
|
||||
deactivated, COALESCE(shadow_banned, FALSE) AS shadow_banned,
|
||||
COALESCE(approved, TRUE) AS approved
|
||||
FROM users
|
||||
WHERE name = ?
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
rows = self.db_pool.cursor_to_dict(txn)
|
||||
|
||||
if len(rows) == 0:
|
||||
return None
|
||||
|
||||
return rows[0]
|
||||
|
||||
row = await self.db_pool.runInteraction(
|
||||
desc="get_user_by_id",
|
||||
func=get_user_by_id_txn,
|
||||
)
|
||||
|
||||
if row is not None:
|
||||
# If we're using SQLite our boolean values will be integers. Because we
|
||||
# present some of this data as is to e.g. server admins via REST APIs, we
|
||||
# want to make sure we're returning the right type of data.
|
||||
# Note: when adding a column name to this list, be wary of NULLable columns,
|
||||
# since NULL values will be turned into False.
|
||||
boolean_columns = ["admin", "deactivated", "shadow_banned", "approved"]
|
||||
for column in boolean_columns:
|
||||
if not isinstance(row[column], bool):
|
||||
row[column] = bool(row[column])
|
||||
|
||||
return row
|
||||
|
||||
async def get_userinfo_by_id(self, user_id: str) -> Optional[UserInfo]:
|
||||
"""Get a UserInfo object for a user by user ID.
|
||||
|
||||
|
@ -1779,6 +1801,40 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
|
|||
|
||||
return res if res else False
|
||||
|
||||
@cached()
|
||||
async def is_user_approved(self, user_id: str) -> bool:
|
||||
"""Checks if a user is approved and therefore can be allowed to log in.
|
||||
|
||||
If the user's 'approved' column is NULL, we consider it as true given it means
|
||||
the user was registered when support for an approval flow was either disabled
|
||||
or nonexistent.
|
||||
|
||||
Args:
|
||||
user_id: the user to check the approval status of.
|
||||
|
||||
Returns:
|
||||
A boolean that is True if the user is approved, False otherwise.
|
||||
"""
|
||||
|
||||
def is_user_approved_txn(txn: LoggingTransaction) -> bool:
|
||||
txn.execute(
|
||||
"""
|
||||
SELECT COALESCE(approved, TRUE) AS approved FROM users WHERE name = ?
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
rows = self.db_pool.cursor_to_dict(txn)
|
||||
|
||||
# We cast to bool because the value returned by the database engine might
|
||||
# be an integer if we're using SQLite.
|
||||
return bool(rows[0]["approved"])
|
||||
|
||||
return await self.db_pool.runInteraction(
|
||||
desc="is_user_pending_approval",
|
||||
func=is_user_approved_txn,
|
||||
)
|
||||
|
||||
|
||||
class RegistrationBackgroundUpdateStore(RegistrationWorkerStore):
|
||||
def __init__(
|
||||
|
@ -1916,6 +1972,29 @@ class RegistrationBackgroundUpdateStore(RegistrationWorkerStore):
|
|||
self._invalidate_cache_and_stream(txn, self.get_user_by_id, (user_id,))
|
||||
txn.call_after(self.is_guest.invalidate, (user_id,))
|
||||
|
||||
def update_user_approval_status_txn(
|
||||
self, txn: LoggingTransaction, user_id: str, approved: bool
|
||||
) -> None:
|
||||
"""Set the user's 'approved' flag to the given value.
|
||||
|
||||
The boolean is turned into an int because the column is a smallint.
|
||||
|
||||
Args:
|
||||
txn: the current database transaction.
|
||||
user_id: the user to update the flag for.
|
||||
approved: the value to set the flag to.
|
||||
"""
|
||||
self.db_pool.simple_update_one_txn(
|
||||
txn=txn,
|
||||
table="users",
|
||||
keyvalues={"name": user_id},
|
||||
updatevalues={"approved": approved},
|
||||
)
|
||||
|
||||
# Invalidate the caches of methods that read the value of the 'approved' flag.
|
||||
self._invalidate_cache_and_stream(txn, self.get_user_by_id, (user_id,))
|
||||
self._invalidate_cache_and_stream(txn, self.is_user_approved, (user_id,))
|
||||
|
||||
|
||||
class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore):
|
||||
def __init__(
|
||||
|
@ -1933,6 +2012,13 @@ class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore):
|
|||
self._access_tokens_id_gen = IdGenerator(db_conn, "access_tokens", "id")
|
||||
self._refresh_tokens_id_gen = IdGenerator(db_conn, "refresh_tokens", "id")
|
||||
|
||||
# If support for MSC3866 is enabled and configured to require approval for new
|
||||
# account, we will create new users with an 'approved' flag set to false.
|
||||
self._require_approval = (
|
||||
hs.config.experimental.msc3866.enabled
|
||||
and hs.config.experimental.msc3866.require_approval_for_new_accounts
|
||||
)
|
||||
|
||||
async def add_access_token_to_user(
|
||||
self,
|
||||
user_id: str,
|
||||
|
@ -2065,6 +2151,7 @@ class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore):
|
|||
admin: bool = False,
|
||||
user_type: Optional[str] = None,
|
||||
shadow_banned: bool = False,
|
||||
approved: bool = False,
|
||||
) -> None:
|
||||
"""Attempts to register an account.
|
||||
|
||||
|
@ -2083,6 +2170,8 @@ class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore):
|
|||
or None for a normal user.
|
||||
shadow_banned: Whether the user is shadow-banned, i.e. they may be
|
||||
told their requests succeeded but we ignore them.
|
||||
approved: Whether to consider the user has already been approved by an
|
||||
administrator.
|
||||
|
||||
Raises:
|
||||
StoreError if the user_id could not be registered.
|
||||
|
@ -2099,6 +2188,7 @@ class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore):
|
|||
admin,
|
||||
user_type,
|
||||
shadow_banned,
|
||||
approved,
|
||||
)
|
||||
|
||||
def _register_user(
|
||||
|
@ -2113,11 +2203,14 @@ class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore):
|
|||
admin: bool,
|
||||
user_type: Optional[str],
|
||||
shadow_banned: bool,
|
||||
approved: bool,
|
||||
) -> None:
|
||||
user_id_obj = UserID.from_string(user_id)
|
||||
|
||||
now = int(self._clock.time())
|
||||
|
||||
user_approved = approved or not self._require_approval
|
||||
|
||||
try:
|
||||
if was_guest:
|
||||
# Ensure that the guest user actually exists
|
||||
|
@ -2143,6 +2236,7 @@ class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore):
|
|||
"admin": 1 if admin else 0,
|
||||
"user_type": user_type,
|
||||
"shadow_banned": shadow_banned,
|
||||
"approved": user_approved,
|
||||
},
|
||||
)
|
||||
else:
|
||||
|
@ -2158,6 +2252,7 @@ class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore):
|
|||
"admin": 1 if admin else 0,
|
||||
"user_type": user_type,
|
||||
"shadow_banned": shadow_banned,
|
||||
"approved": user_approved,
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -2503,6 +2598,25 @@ class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore):
|
|||
start_or_continue_validation_session_txn,
|
||||
)
|
||||
|
||||
async def update_user_approval_status(
|
||||
self, user_id: UserID, approved: bool
|
||||
) -> None:
|
||||
"""Set the user's 'approved' flag to the given value.
|
||||
|
||||
The boolean will be turned into an int (in update_user_approval_status_txn)
|
||||
because the column is a smallint.
|
||||
|
||||
Args:
|
||||
user_id: the user to update the flag for.
|
||||
approved: the value to set the flag to.
|
||||
"""
|
||||
await self.db_pool.runInteraction(
|
||||
"update_user_approval_status",
|
||||
self.update_user_approval_status_txn,
|
||||
user_id.to_string(),
|
||||
approved,
|
||||
)
|
||||
|
||||
|
||||
def find_max_generated_user_id_localpart(cur: Cursor) -> int:
|
||||
"""
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
/* Copyright 2022 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.
|
||||
*/
|
||||
|
||||
-- Add a column to the users table to track whether the user needs to be approved by an
|
||||
-- administrator.
|
||||
-- A NULL column means the user was created before this feature was supported by Synapse,
|
||||
-- and should be considered as TRUE.
|
||||
ALTER TABLE users ADD COLUMN approved BOOLEAN;
|
|
@ -25,10 +25,10 @@ from parameterized import parameterized, parameterized_class
|
|||
from twisted.test.proto_helpers import MemoryReactor
|
||||
|
||||
import synapse.rest.admin
|
||||
from synapse.api.constants import UserTypes
|
||||
from synapse.api.constants import ApprovalNoticeMedium, LoginType, UserTypes
|
||||
from synapse.api.errors import Codes, HttpResponseException, ResourceLimitError
|
||||
from synapse.api.room_versions import RoomVersions
|
||||
from synapse.rest.client import devices, login, logout, profile, room, sync
|
||||
from synapse.rest.client import devices, login, logout, profile, register, room, sync
|
||||
from synapse.rest.media.v1.filepath import MediaFilePaths
|
||||
from synapse.server import HomeServer
|
||||
from synapse.types import JsonDict, UserID
|
||||
|
@ -578,6 +578,16 @@ class UsersListTestCase(unittest.HomeserverTestCase):
|
|||
_search_test(None, "foo", "user_id")
|
||||
_search_test(None, "bar", "user_id")
|
||||
|
||||
@override_config(
|
||||
{
|
||||
"experimental_features": {
|
||||
"msc3866": {
|
||||
"enabled": True,
|
||||
"require_approval_for_new_accounts": True,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_invalid_parameter(self) -> None:
|
||||
"""
|
||||
If parameters are invalid, an error is returned.
|
||||
|
@ -623,6 +633,16 @@ class UsersListTestCase(unittest.HomeserverTestCase):
|
|||
self.assertEqual(400, channel.code, msg=channel.json_body)
|
||||
self.assertEqual(Codes.INVALID_PARAM, channel.json_body["errcode"])
|
||||
|
||||
# invalid approved
|
||||
channel = self.make_request(
|
||||
"GET",
|
||||
self.url + "?approved=not_bool",
|
||||
access_token=self.admin_user_tok,
|
||||
)
|
||||
|
||||
self.assertEqual(400, channel.code, msg=channel.json_body)
|
||||
self.assertEqual(Codes.INVALID_PARAM, channel.json_body["errcode"])
|
||||
|
||||
# unkown order_by
|
||||
channel = self.make_request(
|
||||
"GET",
|
||||
|
@ -841,6 +861,69 @@ class UsersListTestCase(unittest.HomeserverTestCase):
|
|||
self._order_test([self.admin_user, user1, user2], "creation_ts", "f")
|
||||
self._order_test([user2, user1, self.admin_user], "creation_ts", "b")
|
||||
|
||||
@override_config(
|
||||
{
|
||||
"experimental_features": {
|
||||
"msc3866": {
|
||||
"enabled": True,
|
||||
"require_approval_for_new_accounts": True,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_filter_out_approved(self) -> None:
|
||||
"""Tests that the endpoint can filter out approved users."""
|
||||
# Create our users.
|
||||
self._create_users(2)
|
||||
|
||||
# Get the list of users.
|
||||
channel = self.make_request(
|
||||
"GET",
|
||||
self.url,
|
||||
access_token=self.admin_user_tok,
|
||||
)
|
||||
self.assertEqual(200, channel.code, channel.result)
|
||||
|
||||
# Exclude the admin, because we don't want to accidentally un-approve the admin.
|
||||
non_admin_user_ids = [
|
||||
user["name"]
|
||||
for user in channel.json_body["users"]
|
||||
if user["name"] != self.admin_user
|
||||
]
|
||||
|
||||
self.assertEqual(2, len(non_admin_user_ids), non_admin_user_ids)
|
||||
|
||||
# Select a user and un-approve them. We do this rather than the other way around
|
||||
# because, since these users are created by an admin, we consider them already
|
||||
# approved.
|
||||
not_approved_user = non_admin_user_ids[0]
|
||||
|
||||
channel = self.make_request(
|
||||
"PUT",
|
||||
f"/_synapse/admin/v2/users/{not_approved_user}",
|
||||
{"approved": False},
|
||||
access_token=self.admin_user_tok,
|
||||
)
|
||||
self.assertEqual(200, channel.code, channel.result)
|
||||
|
||||
# Now get the list of users again, this time filtering out approved users.
|
||||
channel = self.make_request(
|
||||
"GET",
|
||||
self.url + "?approved=false",
|
||||
access_token=self.admin_user_tok,
|
||||
)
|
||||
self.assertEqual(200, channel.code, channel.result)
|
||||
|
||||
non_admin_user_ids = [
|
||||
user["name"]
|
||||
for user in channel.json_body["users"]
|
||||
if user["name"] != self.admin_user
|
||||
]
|
||||
|
||||
# We should only have our unapproved user now.
|
||||
self.assertEqual(1, len(non_admin_user_ids), non_admin_user_ids)
|
||||
self.assertEqual(not_approved_user, non_admin_user_ids[0])
|
||||
|
||||
def _order_test(
|
||||
self,
|
||||
expected_user_list: List[str],
|
||||
|
@ -1272,6 +1355,7 @@ class UserRestTestCase(unittest.HomeserverTestCase):
|
|||
synapse.rest.admin.register_servlets,
|
||||
login.register_servlets,
|
||||
sync.register_servlets,
|
||||
register.register_servlets,
|
||||
]
|
||||
|
||||
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
||||
|
@ -2536,6 +2620,104 @@ class UserRestTestCase(unittest.HomeserverTestCase):
|
|||
# Ensure they're still alive
|
||||
self.assertEqual(0, channel.json_body["deactivated"])
|
||||
|
||||
@override_config(
|
||||
{
|
||||
"experimental_features": {
|
||||
"msc3866": {
|
||||
"enabled": True,
|
||||
"require_approval_for_new_accounts": True,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_approve_account(self) -> None:
|
||||
"""Tests that approving an account correctly sets the approved flag for the user."""
|
||||
url = self.url_prefix % "@bob:test"
|
||||
|
||||
# Create the user using the client-server API since otherwise the user will be
|
||||
# marked as approved automatically.
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
"register",
|
||||
{
|
||||
"username": "bob",
|
||||
"password": "test",
|
||||
"auth": {"type": LoginType.DUMMY},
|
||||
},
|
||||
)
|
||||
self.assertEqual(403, channel.code, channel.result)
|
||||
self.assertEqual(Codes.USER_AWAITING_APPROVAL, channel.json_body["errcode"])
|
||||
self.assertEqual(
|
||||
ApprovalNoticeMedium.NONE, channel.json_body["approval_notice_medium"]
|
||||
)
|
||||
|
||||
# Get user
|
||||
channel = self.make_request(
|
||||
"GET",
|
||||
url,
|
||||
access_token=self.admin_user_tok,
|
||||
)
|
||||
|
||||
self.assertEqual(200, channel.code, msg=channel.json_body)
|
||||
self.assertIs(False, channel.json_body["approved"])
|
||||
|
||||
# Approve user
|
||||
channel = self.make_request(
|
||||
"PUT",
|
||||
url,
|
||||
access_token=self.admin_user_tok,
|
||||
content={"approved": True},
|
||||
)
|
||||
|
||||
self.assertEqual(200, channel.code, msg=channel.json_body)
|
||||
self.assertIs(True, channel.json_body["approved"])
|
||||
|
||||
# Check that the user is now approved
|
||||
channel = self.make_request(
|
||||
"GET",
|
||||
url,
|
||||
access_token=self.admin_user_tok,
|
||||
)
|
||||
|
||||
self.assertEqual(200, channel.code, msg=channel.json_body)
|
||||
self.assertIs(True, channel.json_body["approved"])
|
||||
|
||||
@override_config(
|
||||
{
|
||||
"experimental_features": {
|
||||
"msc3866": {
|
||||
"enabled": True,
|
||||
"require_approval_for_new_accounts": True,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_register_approved(self) -> None:
|
||||
url = self.url_prefix % "@bob:test"
|
||||
|
||||
# Create user
|
||||
channel = self.make_request(
|
||||
"PUT",
|
||||
url,
|
||||
access_token=self.admin_user_tok,
|
||||
content={"password": "abc123", "approved": True},
|
||||
)
|
||||
|
||||
self.assertEqual(201, channel.code, msg=channel.json_body)
|
||||
self.assertEqual("@bob:test", channel.json_body["name"])
|
||||
self.assertEqual(1, channel.json_body["approved"])
|
||||
|
||||
# Get user
|
||||
channel = self.make_request(
|
||||
"GET",
|
||||
url,
|
||||
access_token=self.admin_user_tok,
|
||||
)
|
||||
|
||||
self.assertEqual(200, channel.code, msg=channel.json_body)
|
||||
self.assertEqual("@bob:test", channel.json_body["name"])
|
||||
self.assertEqual(1, channel.json_body["approved"])
|
||||
|
||||
def _is_erased(self, user_id: str, expect: bool) -> None:
|
||||
"""Assert that the user is erased or not"""
|
||||
d = self.store.is_user_erased(user_id)
|
||||
|
|
|
@ -20,7 +20,8 @@ from twisted.test.proto_helpers import MemoryReactor
|
|||
from twisted.web.resource import Resource
|
||||
|
||||
import synapse.rest.admin
|
||||
from synapse.api.constants import LoginType
|
||||
from synapse.api.constants import ApprovalNoticeMedium, LoginType
|
||||
from synapse.api.errors import Codes
|
||||
from synapse.handlers.ui_auth.checkers import UserInteractiveAuthChecker
|
||||
from synapse.rest.client import account, auth, devices, login, logout, register
|
||||
from synapse.rest.synapse.client import build_synapse_client_resource_tree
|
||||
|
@ -567,6 +568,36 @@ class UIAuthTests(unittest.HomeserverTestCase):
|
|||
body={"auth": {"session": session_id}},
|
||||
)
|
||||
|
||||
@skip_unless(HAS_OIDC, "requires OIDC")
|
||||
@override_config(
|
||||
{
|
||||
"oidc_config": TEST_OIDC_CONFIG,
|
||||
"experimental_features": {
|
||||
"msc3866": {
|
||||
"enabled": True,
|
||||
"require_approval_for_new_accounts": True,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_sso_not_approved(self) -> None:
|
||||
"""Tests that if we register a user via SSO while requiring approval for new
|
||||
accounts, we still raise the correct error before logging the user in.
|
||||
"""
|
||||
login_resp = self.helper.login_via_oidc("username", expected_status=403)
|
||||
|
||||
self.assertEqual(login_resp["errcode"], Codes.USER_AWAITING_APPROVAL)
|
||||
self.assertEqual(
|
||||
ApprovalNoticeMedium.NONE, login_resp["approval_notice_medium"]
|
||||
)
|
||||
|
||||
# Check that we didn't register a device for the user during the login attempt.
|
||||
devices = self.get_success(
|
||||
self.hs.get_datastores().main.get_devices_by_user("@username:test")
|
||||
)
|
||||
|
||||
self.assertEqual(len(devices), 0)
|
||||
|
||||
|
||||
class RefreshAuthTests(unittest.HomeserverTestCase):
|
||||
servlets = [
|
||||
|
|
|
@ -23,6 +23,8 @@ from twisted.test.proto_helpers import MemoryReactor
|
|||
from twisted.web.resource import Resource
|
||||
|
||||
import synapse.rest.admin
|
||||
from synapse.api.constants import ApprovalNoticeMedium, LoginType
|
||||
from synapse.api.errors import Codes
|
||||
from synapse.appservice import ApplicationService
|
||||
from synapse.rest.client import devices, login, logout, register
|
||||
from synapse.rest.client.account import WhoamiRestServlet
|
||||
|
@ -94,6 +96,7 @@ class LoginRestServletTestCase(unittest.HomeserverTestCase):
|
|||
logout.register_servlets,
|
||||
devices.register_servlets,
|
||||
lambda hs, http_server: WhoamiRestServlet(hs).register(http_server),
|
||||
register.register_servlets,
|
||||
]
|
||||
|
||||
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
|
||||
|
@ -406,6 +409,44 @@ class LoginRestServletTestCase(unittest.HomeserverTestCase):
|
|||
self.assertEqual(channel.code, 400)
|
||||
self.assertEqual(channel.json_body["errcode"], "M_INVALID_PARAM")
|
||||
|
||||
@override_config(
|
||||
{
|
||||
"experimental_features": {
|
||||
"msc3866": {
|
||||
"enabled": True,
|
||||
"require_approval_for_new_accounts": True,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_require_approval(self) -> None:
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
"register",
|
||||
{
|
||||
"username": "kermit",
|
||||
"password": "monkey",
|
||||
"auth": {"type": LoginType.DUMMY},
|
||||
},
|
||||
)
|
||||
self.assertEqual(403, channel.code, channel.result)
|
||||
self.assertEqual(Codes.USER_AWAITING_APPROVAL, channel.json_body["errcode"])
|
||||
self.assertEqual(
|
||||
ApprovalNoticeMedium.NONE, channel.json_body["approval_notice_medium"]
|
||||
)
|
||||
|
||||
params = {
|
||||
"type": LoginType.PASSWORD,
|
||||
"identifier": {"type": "m.id.user", "user": "kermit"},
|
||||
"password": "monkey",
|
||||
}
|
||||
channel = self.make_request("POST", LOGIN_URL, params)
|
||||
self.assertEqual(403, channel.code, channel.result)
|
||||
self.assertEqual(Codes.USER_AWAITING_APPROVAL, channel.json_body["errcode"])
|
||||
self.assertEqual(
|
||||
ApprovalNoticeMedium.NONE, channel.json_body["approval_notice_medium"]
|
||||
)
|
||||
|
||||
|
||||
@skip_unless(has_saml2 and HAS_OIDC, "Requires SAML2 and OIDC")
|
||||
class MultiSSOTestCase(unittest.HomeserverTestCase):
|
||||
|
|
|
@ -22,7 +22,11 @@ import pkg_resources
|
|||
from twisted.test.proto_helpers import MemoryReactor
|
||||
|
||||
import synapse.rest.admin
|
||||
from synapse.api.constants import APP_SERVICE_REGISTRATION_TYPE, LoginType
|
||||
from synapse.api.constants import (
|
||||
APP_SERVICE_REGISTRATION_TYPE,
|
||||
ApprovalNoticeMedium,
|
||||
LoginType,
|
||||
)
|
||||
from synapse.api.errors import Codes
|
||||
from synapse.appservice import ApplicationService
|
||||
from synapse.rest.client import account, account_validity, login, logout, register, sync
|
||||
|
@ -765,6 +769,32 @@ class RegisterRestServletTestCase(unittest.HomeserverTestCase):
|
|||
self.assertEqual(channel.code, 400, channel.json_body)
|
||||
self.assertEqual(channel.json_body["errcode"], Codes.USER_IN_USE)
|
||||
|
||||
@override_config(
|
||||
{
|
||||
"experimental_features": {
|
||||
"msc3866": {
|
||||
"enabled": True,
|
||||
"require_approval_for_new_accounts": True,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_require_approval(self) -> None:
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
"register",
|
||||
{
|
||||
"username": "kermit",
|
||||
"password": "monkey",
|
||||
"auth": {"type": LoginType.DUMMY},
|
||||
},
|
||||
)
|
||||
self.assertEqual(403, channel.code, channel.result)
|
||||
self.assertEqual(Codes.USER_AWAITING_APPROVAL, channel.json_body["errcode"])
|
||||
self.assertEqual(
|
||||
ApprovalNoticeMedium.NONE, channel.json_body["approval_notice_medium"]
|
||||
)
|
||||
|
||||
|
||||
class AccountValidityTestCase(unittest.HomeserverTestCase):
|
||||
|
||||
|
|
|
@ -543,8 +543,12 @@ class RestHelper:
|
|||
|
||||
return channel.json_body
|
||||
|
||||
def login_via_oidc(self, remote_user_id: str) -> JsonDict:
|
||||
"""Log in (as a new user) via OIDC
|
||||
def login_via_oidc(
|
||||
self,
|
||||
remote_user_id: str,
|
||||
expected_status: int = 200,
|
||||
) -> JsonDict:
|
||||
"""Log in via OIDC
|
||||
|
||||
Returns the result of the final token login.
|
||||
|
||||
|
@ -578,7 +582,9 @@ class RestHelper:
|
|||
"/login",
|
||||
content={"type": "m.login.token", "token": login_token},
|
||||
)
|
||||
assert channel.code == HTTPStatus.OK
|
||||
assert (
|
||||
channel.code == expected_status
|
||||
), f"unexpected status in response: {channel.code}"
|
||||
return channel.json_body
|
||||
|
||||
def auth_via_oidc(
|
||||
|
|
|
@ -16,9 +16,10 @@ from twisted.test.proto_helpers import MemoryReactor
|
|||
from synapse.api.constants import UserTypes
|
||||
from synapse.api.errors import ThreepidValidationError
|
||||
from synapse.server import HomeServer
|
||||
from synapse.types import JsonDict, UserID
|
||||
from synapse.util import Clock
|
||||
|
||||
from tests.unittest import HomeserverTestCase
|
||||
from tests.unittest import HomeserverTestCase, override_config
|
||||
|
||||
|
||||
class RegistrationStoreTestCase(HomeserverTestCase):
|
||||
|
@ -48,6 +49,7 @@ class RegistrationStoreTestCase(HomeserverTestCase):
|
|||
"user_type": None,
|
||||
"deactivated": 0,
|
||||
"shadow_banned": 0,
|
||||
"approved": 1,
|
||||
},
|
||||
(self.get_success(self.store.get_user_by_id(self.user_id))),
|
||||
)
|
||||
|
@ -166,3 +168,101 @@ class RegistrationStoreTestCase(HomeserverTestCase):
|
|||
ThreepidValidationError,
|
||||
)
|
||||
self.assertEqual(e.value.msg, "Validation token not found or has expired", e)
|
||||
|
||||
|
||||
class ApprovalRequiredRegistrationTestCase(HomeserverTestCase):
|
||||
def default_config(self) -> JsonDict:
|
||||
config = super().default_config()
|
||||
|
||||
# If there's already some config for this feature in the default config, it
|
||||
# means we're overriding it with @override_config. In this case we don't want
|
||||
# to do anything more with it.
|
||||
msc3866_config = config.get("experimental_features", {}).get("msc3866")
|
||||
if msc3866_config is not None:
|
||||
return config
|
||||
|
||||
# Require approval for all new accounts.
|
||||
config["experimental_features"] = {
|
||||
"msc3866": {
|
||||
"enabled": True,
|
||||
"require_approval_for_new_accounts": True,
|
||||
}
|
||||
}
|
||||
return config
|
||||
|
||||
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
||||
self.store = hs.get_datastores().main
|
||||
self.user_id = "@my-user:test"
|
||||
self.pwhash = "{xx1}123456789"
|
||||
|
||||
@override_config(
|
||||
{
|
||||
"experimental_features": {
|
||||
"msc3866": {
|
||||
"enabled": True,
|
||||
"require_approval_for_new_accounts": False,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_approval_not_required(self) -> None:
|
||||
"""Tests that if we don't require approval for new accounts, newly created
|
||||
accounts are automatically marked as approved.
|
||||
"""
|
||||
self.get_success(self.store.register_user(self.user_id, self.pwhash))
|
||||
|
||||
user = self.get_success(self.store.get_user_by_id(self.user_id))
|
||||
assert user is not None
|
||||
self.assertTrue(user["approved"])
|
||||
|
||||
approved = self.get_success(self.store.is_user_approved(self.user_id))
|
||||
self.assertTrue(approved)
|
||||
|
||||
def test_approval_required(self) -> None:
|
||||
"""Tests that if we require approval for new accounts, newly created accounts
|
||||
are not automatically marked as approved.
|
||||
"""
|
||||
self.get_success(self.store.register_user(self.user_id, self.pwhash))
|
||||
|
||||
user = self.get_success(self.store.get_user_by_id(self.user_id))
|
||||
assert user is not None
|
||||
self.assertFalse(user["approved"])
|
||||
|
||||
approved = self.get_success(self.store.is_user_approved(self.user_id))
|
||||
self.assertFalse(approved)
|
||||
|
||||
def test_override(self) -> None:
|
||||
"""Tests that if we require approval for new accounts, but we explicitly say the
|
||||
new user should be considered approved, they're marked as approved.
|
||||
"""
|
||||
self.get_success(
|
||||
self.store.register_user(
|
||||
self.user_id,
|
||||
self.pwhash,
|
||||
approved=True,
|
||||
)
|
||||
)
|
||||
|
||||
user = self.get_success(self.store.get_user_by_id(self.user_id))
|
||||
self.assertIsNotNone(user)
|
||||
assert user is not None
|
||||
self.assertEqual(user["approved"], 1)
|
||||
|
||||
approved = self.get_success(self.store.is_user_approved(self.user_id))
|
||||
self.assertTrue(approved)
|
||||
|
||||
def test_approve_user(self) -> None:
|
||||
"""Tests that approving the user updates their approval status."""
|
||||
self.get_success(self.store.register_user(self.user_id, self.pwhash))
|
||||
|
||||
approved = self.get_success(self.store.is_user_approved(self.user_id))
|
||||
self.assertFalse(approved)
|
||||
|
||||
self.get_success(
|
||||
self.store.update_user_approval_status(
|
||||
UserID.from_string(self.user_id), True
|
||||
)
|
||||
)
|
||||
|
||||
approved = self.get_success(self.store.is_user_approved(self.user_id))
|
||||
self.assertTrue(approved)
|
||||
|
|
Loading…
Reference in a new issue