Merge pull request #177 from anoadragon453/anoa/synapse_guest_module_mas
Make the Synapse Restricted Guests module compatible with MAS
This commit is contained in:
@@ -41,6 +41,18 @@ The module provides (optional) configuration options:
|
||||
- `enable_user_reaper` - if true, the module disables all users that are older than the configured expiration time. Default: `true`.
|
||||
- `user_expiration_seconds` - the expiration time in seconds when a guest user expires after their creation. Default: `86400` (=24 hours).
|
||||
|
||||
If matrix-authentication-service (MAS) is configured, the module will need to
|
||||
interface with it in order to register/deactivate users. Provide the below
|
||||
options in order to give the module access to [MAS' Admin
|
||||
API](https://element-hq.github.io/matrix-authentication-service/topics/admin-api.html).
|
||||
|
||||
- `mas` - optional configuration for Matrix Authentication Service (MAS). When set, the module creates users via MAS' admin API.
|
||||
- `admin_api_base_url` - Base URL for MAS' admin API (e.g. `https://mas.example.org`). Trailing slashes will be automatically stripped.
|
||||
- `oauth_base_url` - Base URL for MAS' OAuth endpoints (defaults to `admin_api_base_url` if not set). Trailing slashes will be automatically stripped.
|
||||
- `client_id` - client ID for the automated tool. Must be a valid [ULID](https://github.com/ulid/spec). Generate one [here](https://ulidtools.com/).
|
||||
- `client_secret` - client secret for the automated tool. Ideally long and cryptographically secure. Keep it a secret!
|
||||
- `client_secret_filepath` - path to a plaintext file containing the client secret. If set, this is used instead of `client_secret`.
|
||||
|
||||
Example configuration:
|
||||
|
||||
```yaml
|
||||
@@ -49,6 +61,39 @@ modules:
|
||||
config:
|
||||
# Use a german suffix
|
||||
display_name_suffix: " (Gast)"
|
||||
# The below is required if using MAS
|
||||
mas:
|
||||
admin_api_base_url: https://mas.example.org
|
||||
oauth_base_url: https://mas.example.org
|
||||
# The `client_id` must be a valid ULID:
|
||||
# https://github.com/ulid/spec
|
||||
# Generate ULID's easily at:
|
||||
# https://ulidtools.com/
|
||||
client_id: 000000000000000000000G0EST
|
||||
client_secret: your-client-secret
|
||||
# Alternatively, load the secret from a file:
|
||||
# client_secret_filepath: /run/secrets/mas-client-secret
|
||||
```
|
||||
|
||||
Enable [the Admin API on a MAS
|
||||
listener](https://element-hq.github.io/matrix-authentication-service/topics/admin-api.html#enabling-the-api).
|
||||
Then, add the following to your MAS config file:
|
||||
|
||||
```yaml
|
||||
policy:
|
||||
data:
|
||||
admin_clients:
|
||||
- 000000000000000000000G0EST
|
||||
|
||||
# ...
|
||||
|
||||
clients:
|
||||
# The `client_id` must be a valid ULID https://github.com/ulid/spec
|
||||
# Generate ULID's easily at: https://ulidtools.com/
|
||||
- client_id: 000000000000000000000G0EST
|
||||
# The guest module uses the client_secret_basic authentication method.
|
||||
client_auth_method: client_secret_basic
|
||||
client_secret: your-client-secret
|
||||
```
|
||||
|
||||
## Production installation
|
||||
|
||||
@@ -30,6 +30,7 @@ dev =
|
||||
twisted
|
||||
aiounittest
|
||||
coverage
|
||||
parameterized
|
||||
# for type checking
|
||||
mypy == 1.10.0
|
||||
pydantic == 2.4.2
|
||||
|
||||
@@ -7,12 +7,24 @@
|
||||
# Originally licensed under the Apache License, Version 2.0:
|
||||
# <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import attr
|
||||
|
||||
|
||||
@attr.s(frozen=True, auto_attribs=True)
|
||||
class MasConfig:
|
||||
admin_api_base_url: str
|
||||
oauth_base_url: str
|
||||
client_id: str
|
||||
client_secret: Optional[str] = None
|
||||
client_secret_filepath: Optional[str] = None
|
||||
|
||||
|
||||
@attr.s(frozen=True, auto_attribs=True)
|
||||
class GuestModuleConfig:
|
||||
user_id_prefix: str
|
||||
display_name_suffix: str
|
||||
enable_user_reaper: bool
|
||||
user_expiration_seconds: int
|
||||
mas: Optional[MasConfig] = None
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
# Originally licensed under the Apache License, Version 2.0:
|
||||
# <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict, Literal, Tuple, Union
|
||||
from typing import Any, Dict, Literal, Optional, Tuple, Union
|
||||
|
||||
from synapse.module_api import (
|
||||
NOT_SPAM,
|
||||
LoggingTransaction,
|
||||
ModuleApi,
|
||||
ProfileInfo,
|
||||
UserProfile,
|
||||
@@ -21,9 +23,10 @@ from synapse.module_api import (
|
||||
from synapse.module_api.errors import ConfigError
|
||||
from synapse.types import UserID
|
||||
|
||||
from synapse_guest_module.config import GuestModuleConfig
|
||||
from synapse_guest_module.config import GuestModuleConfig, MasConfig
|
||||
from synapse_guest_module.guest_registration_servlet import GuestRegistrationServlet
|
||||
from synapse_guest_module.guest_user_reaper import GuestUserReaper
|
||||
from synapse_guest_module.mas_admin_client import MasAdminClient
|
||||
|
||||
logger = logging.getLogger("synapse.contrib." + __name__)
|
||||
|
||||
@@ -32,8 +35,21 @@ class GuestModule:
|
||||
def __init__(self, config: GuestModuleConfig, api: ModuleApi):
|
||||
self._api = api
|
||||
self._config = config
|
||||
self._mas_tables_ready: asyncio.Event | None = None
|
||||
|
||||
self.registration_servlet = GuestRegistrationServlet(config, api)
|
||||
mas_admin_client = (
|
||||
MasAdminClient(api, config.mas) if config.mas is not None else None
|
||||
)
|
||||
if config.mas is not None:
|
||||
self._mas_tables_ready = asyncio.Event()
|
||||
run_as_background_process(
|
||||
"guest_module_mas_db_init",
|
||||
self._init_mas_tables,
|
||||
bg_start_span=False,
|
||||
)
|
||||
self.registration_servlet = GuestRegistrationServlet(
|
||||
config, api, mas_admin_client, self._mas_tables_ready
|
||||
)
|
||||
self._api.register_web_resource(
|
||||
"/_synapse/client/register_guest", self.registration_servlet
|
||||
)
|
||||
@@ -48,7 +64,9 @@ class GuestModule:
|
||||
)
|
||||
|
||||
# Start the user reaper
|
||||
self.reaper = GuestUserReaper(api, config)
|
||||
self.reaper = GuestUserReaper(
|
||||
api, config, mas_admin_client, self._mas_tables_ready
|
||||
)
|
||||
if config.enable_user_reaper:
|
||||
run_as_background_process(
|
||||
"guest_module_reaper_bg_task",
|
||||
@@ -81,11 +99,77 @@ class GuestModule:
|
||||
"Config option 'user_expiration_seconds' must be a number"
|
||||
)
|
||||
|
||||
mas_config = config.get("mas")
|
||||
mas: Optional[MasConfig] = None
|
||||
if mas_config is not None:
|
||||
if not isinstance(mas_config, dict):
|
||||
raise ConfigError("Config option 'mas' must be an object")
|
||||
|
||||
admin_api_base_url = mas_config.get("admin_api_base_url")
|
||||
if (
|
||||
not isinstance(admin_api_base_url, str)
|
||||
or len(admin_api_base_url.strip()) == 0
|
||||
):
|
||||
raise ConfigError(
|
||||
"Config option 'mas.admin_api_base_url' is required and must be a string"
|
||||
)
|
||||
|
||||
oauth_base_url = mas_config.get("oauth_base_url", admin_api_base_url)
|
||||
if not isinstance(oauth_base_url, str) or len(oauth_base_url.strip()) == 0:
|
||||
raise ConfigError("Config option 'mas.oauth_base_url' must be a string")
|
||||
|
||||
client_id = mas_config.get("client_id")
|
||||
if not isinstance(client_id, str) or len(client_id.strip()) == 0:
|
||||
raise ConfigError(
|
||||
"Config option 'mas.client_id' is required and must be a string"
|
||||
)
|
||||
|
||||
client_secret = mas_config.get("client_secret")
|
||||
if client_secret is not None:
|
||||
if (
|
||||
not isinstance(client_secret, str)
|
||||
or len(client_secret.strip()) == 0
|
||||
):
|
||||
raise ConfigError(
|
||||
"Config option 'mas.client_secret' must be a string"
|
||||
)
|
||||
client_secret = client_secret.strip()
|
||||
|
||||
client_secret_filepath = mas_config.get("client_secret_filepath")
|
||||
if client_secret_filepath is not None:
|
||||
if (
|
||||
not isinstance(client_secret_filepath, str)
|
||||
or len(client_secret_filepath.strip()) == 0
|
||||
):
|
||||
raise ConfigError(
|
||||
"Config option 'mas.client_secret_filepath' must be a string"
|
||||
)
|
||||
client_secret_filepath = client_secret_filepath.strip()
|
||||
|
||||
if client_secret is None and client_secret_filepath is None:
|
||||
raise ConfigError(
|
||||
"Config option 'mas.client_secret' or 'mas.client_secret_filepath' is required"
|
||||
)
|
||||
|
||||
if client_secret is not None and client_secret_filepath is not None:
|
||||
raise ConfigError(
|
||||
"Config option 'mas.client_secret' and 'mas.client_secret_filepath' are mutually exclusive"
|
||||
)
|
||||
|
||||
mas = MasConfig(
|
||||
admin_api_base_url.strip(),
|
||||
oauth_base_url.strip(),
|
||||
client_id.strip(),
|
||||
client_secret,
|
||||
client_secret_filepath,
|
||||
)
|
||||
|
||||
return GuestModuleConfig(
|
||||
user_id_prefix,
|
||||
display_name_suffix,
|
||||
enable_user_reaper,
|
||||
user_expiration_seconds,
|
||||
mas,
|
||||
)
|
||||
|
||||
async def profile_update(
|
||||
@@ -114,6 +198,40 @@ class GuestModule:
|
||||
)
|
||||
await self._api.set_displayname(user_id_1, guest_display_name)
|
||||
|
||||
async def _init_mas_tables(self) -> None:
|
||||
if self._mas_tables_ready is None:
|
||||
return
|
||||
|
||||
try:
|
||||
await self._api.run_db_interaction(
|
||||
"guest_module_create_mas_tables",
|
||||
self._create_mas_tables,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error("Failed to initialize MAS tables: %s", err)
|
||||
finally:
|
||||
self._mas_tables_ready.set()
|
||||
|
||||
@staticmethod
|
||||
def _create_mas_tables(txn: LoggingTransaction) -> None:
|
||||
txn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS guest_module_mas_users (
|
||||
mas_user_id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at_sec BIGINT NOT NULL
|
||||
)
|
||||
""",
|
||||
(),
|
||||
)
|
||||
txn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS guest_module_mas_users_created_at_sec
|
||||
ON guest_module_mas_users (created_at_sec)
|
||||
""",
|
||||
(),
|
||||
)
|
||||
|
||||
async def callback_user_may_create_room(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
+83
-7
@@ -7,19 +7,24 @@
|
||||
# Originally licensed under the Apache License, Version 2.0:
|
||||
# <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from synapse.module_api import (
|
||||
DatabasePool,
|
||||
DirectServeJsonResource,
|
||||
ModuleApi,
|
||||
parse_json_object_from_request,
|
||||
)
|
||||
from synapse.types import UserID
|
||||
from twisted.web.server import Request
|
||||
|
||||
from synapse_guest_module.config import GuestModuleConfig
|
||||
from synapse_guest_module.mas_admin_client import MasAdminClient
|
||||
|
||||
logger = logging.getLogger("synapse.contrib." + __name__)
|
||||
|
||||
@@ -35,10 +40,14 @@ class GuestRegistrationServlet(DirectServeJsonResource):
|
||||
self,
|
||||
config: GuestModuleConfig,
|
||||
api: ModuleApi,
|
||||
mas_admin_client: MasAdminClient | None = None,
|
||||
mas_tables_ready: asyncio.Event | None = None,
|
||||
):
|
||||
super().__init__()
|
||||
self._api = api
|
||||
self._config = config
|
||||
self._mas_admin_client = mas_admin_client
|
||||
self._mas_tables_ready = mas_tables_ready
|
||||
|
||||
async def _async_render_POST(self, request: Request) -> Tuple[int, Dict[str, Any]]:
|
||||
"""On POST requests, generate a new username for a guest, check that it
|
||||
@@ -52,8 +61,11 @@ class GuestRegistrationServlet(DirectServeJsonResource):
|
||||
if not isinstance(displayname, str) or len(displayname.strip()) == 0:
|
||||
return 400, {"msg": "You must provide a 'displayname' as a string"}
|
||||
|
||||
# make sure the regex is unique
|
||||
displayname = displayname.strip()
|
||||
|
||||
# Attempt up to 10 times to generate a localpart
|
||||
for _ in range(10):
|
||||
# generate a random string as a suffix
|
||||
random_string = "".join(
|
||||
secrets.choice(string.ascii_lowercase + string.digits)
|
||||
for _ in range(32)
|
||||
@@ -67,14 +79,52 @@ class GuestRegistrationServlet(DirectServeJsonResource):
|
||||
):
|
||||
continue
|
||||
|
||||
logger.info("Register guest with user %s", localpart)
|
||||
user_id = await self._api.register_user(
|
||||
localpart, displayname.strip() + self._config.display_name_suffix
|
||||
)
|
||||
if self._mas_admin_client is None:
|
||||
logger.info(
|
||||
"Registering local Synapse guest user with localpart '%s'",
|
||||
localpart,
|
||||
)
|
||||
user_id = await self._api.register_user(
|
||||
localpart, displayname + self._config.display_name_suffix
|
||||
)
|
||||
|
||||
device_id, access_token, _, _ = await self._api.register_device(user_id)
|
||||
device_id, access_token, _, _ = await self._api.register_device(user_id)
|
||||
else:
|
||||
logger.info("Registering MAS guest user with localpart '%s'", localpart)
|
||||
|
||||
logger.debug("Registered user %s", user_id)
|
||||
# This will be the MAS-specific user ID (i.e. "01KFNJEB720EAGR907PSXRXQ51")
|
||||
mas_user_id = await self._mas_admin_client.create_user(localpart)
|
||||
# This is the Matrix user ID (i.e. "@guest_abc123:matrix.org")
|
||||
user_id = self._api.get_qualified_user_id(localpart)
|
||||
|
||||
logger.info(
|
||||
f"Registered guest user: '{user_id}' (MAS ID: '{mas_user_id}')"
|
||||
)
|
||||
|
||||
await self._api.set_displayname(
|
||||
UserID.from_string(user_id),
|
||||
displayname + self._config.display_name_suffix,
|
||||
)
|
||||
|
||||
await self._store_mas_user(mas_user_id, user_id, int(time.time()))
|
||||
|
||||
# Determine how long to keep the access token valid for.
|
||||
#
|
||||
# If a user reaper is enabled, just have the token expire after
|
||||
# the configured period.
|
||||
expires_in_sec = (
|
||||
self._config.user_expiration_seconds
|
||||
if self._config.enable_user_reaper
|
||||
else 0
|
||||
)
|
||||
(
|
||||
device_id,
|
||||
access_token,
|
||||
) = await self._mas_admin_client.create_personal_session(
|
||||
mas_user_id, expires_in_sec
|
||||
)
|
||||
|
||||
logger.debug("Registered user '%s'", user_id)
|
||||
|
||||
res = {
|
||||
"userId": user_id,
|
||||
@@ -86,3 +136,29 @@ class GuestRegistrationServlet(DirectServeJsonResource):
|
||||
return 201, res
|
||||
|
||||
return 500, {"msg": "Internal error: Could not find a free username"}
|
||||
|
||||
async def _store_mas_user(
|
||||
self, mas_user_id: str, user_id: str, created_at_sec: int
|
||||
) -> None:
|
||||
"""Store details about the MAS user in the DB
|
||||
|
||||
Args:
|
||||
mas_user_id: The MAS user ID
|
||||
user_id: The Matrix user ID
|
||||
created_at_sec: The creation timestamp in seconds since the unix epoch
|
||||
"""
|
||||
if self._mas_tables_ready is not None:
|
||||
await self._mas_tables_ready.wait()
|
||||
|
||||
def store_user(txn: Any) -> None:
|
||||
DatabasePool.simple_insert_txn(
|
||||
txn,
|
||||
table="guest_module_mas_users",
|
||||
values={
|
||||
"mas_user_id": mas_user_id,
|
||||
"user_id": user_id,
|
||||
"created_at_sec": created_at_sec,
|
||||
},
|
||||
)
|
||||
|
||||
await self._api.run_db_interaction("guest_module_store_mas_user", store_user)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
# Originally licensed under the Apache License, Version 2.0:
|
||||
# <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import List
|
||||
@@ -14,14 +15,23 @@ from typing import List
|
||||
from synapse.module_api import DatabasePool, LoggingTransaction, ModuleApi
|
||||
|
||||
from synapse_guest_module.config import GuestModuleConfig
|
||||
from synapse_guest_module.mas_admin_client import MasAdminClient
|
||||
|
||||
logger = logging.getLogger("synapse.contrib." + __name__)
|
||||
|
||||
|
||||
class GuestUserReaper:
|
||||
def __init__(self, api: ModuleApi, config: GuestModuleConfig):
|
||||
def __init__(
|
||||
self,
|
||||
api: ModuleApi,
|
||||
config: GuestModuleConfig,
|
||||
mas_admin_client: MasAdminClient | None = None,
|
||||
mas_tables_ready: asyncio.Event | None = None,
|
||||
):
|
||||
self._api = api
|
||||
self._config = config
|
||||
self._mas_admin_client = mas_admin_client
|
||||
self._mas_tables_ready = mas_tables_ready
|
||||
self.reaper_user = f"{config.user_id_prefix}reaper"
|
||||
|
||||
async def run(self) -> None:
|
||||
@@ -43,6 +53,9 @@ class GuestUserReaper:
|
||||
"""Deactivate all users that are older than the specified expiration
|
||||
interval. This uses the admin API to disable the user.
|
||||
"""
|
||||
if self._mas_admin_client is not None:
|
||||
await self._deactivate_expired_mas_users()
|
||||
return
|
||||
|
||||
def get_expired_users(txn: LoggingTransaction) -> List[str]:
|
||||
sql = """
|
||||
@@ -92,6 +105,61 @@ class GuestUserReaper:
|
||||
except Exception as e:
|
||||
logger.error('Failed to delete user "%s": %s', user_id, e)
|
||||
|
||||
async def _deactivate_expired_mas_users(self) -> None:
|
||||
"""Deactivate all MAS users that are older than the specified expiration
|
||||
interval. This uses the MAS admin API to disable the user.
|
||||
"""
|
||||
|
||||
assert self._mas_admin_client is not None
|
||||
|
||||
if self._mas_tables_ready is not None:
|
||||
await self._mas_tables_ready.wait()
|
||||
|
||||
def get_expired_users(txn: LoggingTransaction) -> List[str]:
|
||||
expire_ts_seconds = int(time.time() - self._config.user_expiration_seconds)
|
||||
txn.execute(
|
||||
"""
|
||||
SELECT mas_user_id
|
||||
FROM guest_module_mas_users
|
||||
WHERE created_at_sec < ?
|
||||
""",
|
||||
(expire_ts_seconds,),
|
||||
)
|
||||
expired_users_rows = txn.fetchall()
|
||||
|
||||
return [row[0] for row in expired_users_rows]
|
||||
|
||||
expired_users: List[str] = await self._api.run_db_interaction(
|
||||
"guest_module_get_expired_mas_users",
|
||||
get_expired_users,
|
||||
)
|
||||
|
||||
if len(expired_users) == 0:
|
||||
return
|
||||
|
||||
logger.info("Deactivating %d expired MAS users", len(expired_users))
|
||||
|
||||
token = await self._mas_admin_client.request_admin_token()
|
||||
|
||||
for mas_user_id in expired_users:
|
||||
try:
|
||||
await self._mas_admin_client.deactivate_user(mas_user_id, token)
|
||||
await self._remove_mas_user(mas_user_id)
|
||||
except Exception as e:
|
||||
logger.error('Failed to deactivate MAS user "%s": %s', mas_user_id, e)
|
||||
|
||||
async def _remove_mas_user(self, mas_user_id: str) -> None:
|
||||
def delete_user(txn: LoggingTransaction) -> None:
|
||||
txn.execute(
|
||||
"DELETE FROM guest_module_mas_users WHERE mas_user_id = ?",
|
||||
(mas_user_id,),
|
||||
)
|
||||
|
||||
await self._api.run_db_interaction(
|
||||
"guest_module_delete_mas_user",
|
||||
delete_user,
|
||||
)
|
||||
|
||||
async def get_admin_token(self) -> str:
|
||||
"""Create a new admin user in synapse so the module can call the admin
|
||||
api. If no user or login session exists, we create new ones.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Copyright 2025 New Vector Ltd.
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
# Please see LICENSE files in the project root for full details.
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
|
||||
from synapse.module_api import ModuleApi
|
||||
|
||||
from synapse_guest_module.config import MasConfig
|
||||
|
||||
logger = logging.getLogger("synapse.contrib." + __name__)
|
||||
|
||||
|
||||
class MasAdminClient:
|
||||
def __init__(self, api: ModuleApi, config: MasConfig):
|
||||
self._api = api
|
||||
self._config = config
|
||||
# Strip any trailing slashes if present
|
||||
self._admin_api_base_url = config.admin_api_base_url.rstrip("/")
|
||||
self._oauth_base_url = config.oauth_base_url.rstrip("/")
|
||||
self._client_secret = self._load_client_secret()
|
||||
|
||||
async def create_user(self, username: str) -> str:
|
||||
"""Creates a new user in MAS with the given username.
|
||||
|
||||
Args:
|
||||
username: The username (localpart) of the user to create.
|
||||
|
||||
Returns:
|
||||
The MAS ID of the created user.
|
||||
"""
|
||||
token = await self.request_admin_token()
|
||||
url = self._build_admin_url("/api/admin/v1/users")
|
||||
|
||||
response = await self._api.http_client.post_json_get_json(
|
||||
uri=url,
|
||||
post_json={"username": username},
|
||||
headers={"Authorization": [f"Bearer {token}"]},
|
||||
)
|
||||
|
||||
mas_user_id: str = response.get("data", {}).get("id")
|
||||
if mas_user_id is None or not isinstance(mas_user_id, str):
|
||||
raise ValueError("MAS user creation response missing `data.id` field")
|
||||
|
||||
return mas_user_id
|
||||
|
||||
async def create_personal_session(
|
||||
self, mas_user_id: str, expires_in_sec: int
|
||||
) -> tuple[str, str]:
|
||||
"""Creates a new personal session for the given MAS user.
|
||||
|
||||
Args:
|
||||
mas_user_id: The MAS user ID.
|
||||
expires_in_sec: The session expiration time in seconds.
|
||||
|
||||
Returns:
|
||||
A tuple of (device_id, access_token).
|
||||
"""
|
||||
token = await self.request_admin_token()
|
||||
url = self._build_admin_url("/api/admin/v1/personal-sessions")
|
||||
|
||||
device_id = self._generate_device_id()
|
||||
request_body = {
|
||||
"actor_user_id": mas_user_id,
|
||||
"expires_in": expires_in_sec,
|
||||
"scope": f"openid urn:matrix:client:api:* urn:matrix:client:device:{device_id}",
|
||||
"human_name": "guest session",
|
||||
}
|
||||
|
||||
response = await self._api.http_client.post_json_get_json(
|
||||
uri=url,
|
||||
post_json=request_body,
|
||||
headers={"Authorization": [f"Bearer {token}"]},
|
||||
)
|
||||
|
||||
data = response.get("data", {})
|
||||
attributes = data.get("attributes", {}) if isinstance(data, dict) else {}
|
||||
access_token = attributes.get("access_token")
|
||||
|
||||
if not isinstance(access_token, str) or len(access_token) == 0:
|
||||
raise ValueError("MAS session response missing `access_token` field")
|
||||
|
||||
return device_id, access_token
|
||||
|
||||
async def deactivate_user(self, mas_user_id: str, token: str | None = None) -> None:
|
||||
if token is None:
|
||||
token = await self.request_admin_token()
|
||||
|
||||
url = self._build_admin_url(f"/api/admin/v1/users/{mas_user_id}/deactivate")
|
||||
await self._api.http_client.post_json_get_json(
|
||||
uri=url,
|
||||
post_json={"skip_erase": True},
|
||||
headers={"Authorization": [f"Bearer {token}"]},
|
||||
)
|
||||
|
||||
async def request_admin_token(self) -> str:
|
||||
"""
|
||||
Uses the client credentials flow to request an admin access token
|
||||
from MAS.
|
||||
|
||||
Returns:
|
||||
The admin access token.
|
||||
|
||||
Raises:
|
||||
ValueError: If the token response is invalid.
|
||||
HttpResponseException: On a non-2xx HTTP response.
|
||||
"""
|
||||
url = self._build_oauth_url("/oauth2/token")
|
||||
basic_auth = base64.b64encode(
|
||||
f"{self._config.client_id}:{self._client_secret}".encode("utf-8")
|
||||
).decode("ascii")
|
||||
headers = {
|
||||
"Authorization": [f"Basic {basic_auth}"],
|
||||
"Content-Type": ["application/x-www-form-urlencoded"],
|
||||
}
|
||||
data = {
|
||||
"grant_type": "client_credentials",
|
||||
"scope": "urn:mas:admin",
|
||||
}
|
||||
|
||||
response = await self._api.http_client.post_urlencoded_get_json(
|
||||
url, data, headers
|
||||
)
|
||||
access_token = response.get("access_token")
|
||||
if not isinstance(access_token, str) or len(access_token) == 0:
|
||||
raise ValueError("MAS token response missing access_token")
|
||||
return access_token
|
||||
|
||||
def _load_client_secret(self) -> str:
|
||||
"""Source the MAS client secret from either configuration or a file."""
|
||||
if self._config.client_secret_filepath is not None:
|
||||
try:
|
||||
with open(
|
||||
self._config.client_secret_filepath, "r", encoding="utf-8"
|
||||
) as secret_file:
|
||||
client_secret = secret_file.read().strip()
|
||||
except Exception as err:
|
||||
raise ValueError(
|
||||
f"Failed to read MAS client secret file: {err}"
|
||||
) from err
|
||||
|
||||
if len(client_secret) == 0:
|
||||
raise ValueError("MAS client secret file is empty")
|
||||
|
||||
return client_secret
|
||||
|
||||
if self._config.client_secret is None:
|
||||
raise ValueError("MAS client secret is not configured")
|
||||
|
||||
client_secret = self._config.client_secret.strip()
|
||||
if len(client_secret) == 0:
|
||||
raise ValueError("MAS client secret is empty")
|
||||
|
||||
return client_secret
|
||||
|
||||
@staticmethod
|
||||
def _generate_device_id() -> str:
|
||||
"""Generate a MAS device ID.
|
||||
|
||||
Device IDs must be at least 10 characters long and contain only
|
||||
[A-Za-z0-9-].
|
||||
|
||||
The generated device ID is purposefully non-cryptographically random,
|
||||
as the value is public.
|
||||
|
||||
Returns:
|
||||
The generated Device ID.
|
||||
"""
|
||||
length = 16
|
||||
alphabet = string.ascii_letters + string.digits + "-"
|
||||
return "".join(random.choices(alphabet, k=length))
|
||||
|
||||
def _build_admin_url(self, path: str) -> str:
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
return f"{self._admin_api_base_url}{path}"
|
||||
|
||||
def _build_oauth_url(self, path: str) -> str:
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
return f"{self._oauth_base_url}{path}"
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import sqlite3
|
||||
from asyncio import Future
|
||||
from typing import Any, Awaitable, Callable, Tuple, TypeVar
|
||||
from typing import Any, Awaitable, Callable, Dict, Tuple, TypeVar
|
||||
from unittest.mock import Mock
|
||||
|
||||
from synapse.http.client import SimpleHttpClient
|
||||
@@ -89,7 +89,9 @@ async def register_user(localpart: str, admin: bool = False) -> str:
|
||||
return f"@{localpart}:matrix.local"
|
||||
|
||||
|
||||
def create_module() -> Tuple[GuestModule, Mock, SQLiteStore]:
|
||||
def create_module(
|
||||
config_override: Dict[str, Any] | None = None,
|
||||
) -> Tuple[GuestModule, Mock, SQLiteStore]:
|
||||
store = SQLiteStore()
|
||||
_setup_db(store.conn)
|
||||
|
||||
@@ -111,19 +113,38 @@ def create_module() -> Tuple[GuestModule, Mock, SQLiteStore]:
|
||||
)
|
||||
|
||||
# If necessary, give parse_config some configuration to parse.
|
||||
config = GuestModule.parse_config(
|
||||
{
|
||||
"enable_user_reaper": False,
|
||||
}
|
||||
)
|
||||
config_dict: Dict[str, Any] = {
|
||||
"enable_user_reaper": False,
|
||||
}
|
||||
if config_override is not None:
|
||||
config_dict.update(config_override)
|
||||
|
||||
config = GuestModule.parse_config(config_dict)
|
||||
|
||||
module = GuestModule(config, module_api)
|
||||
|
||||
if getattr(module, "_mas_tables_ready", None) is not None:
|
||||
module._mas_tables_ready.set() # type: ignore[union-attr]
|
||||
|
||||
return module, module_api, store
|
||||
|
||||
|
||||
def mas_config_override() -> Dict[str, Any]:
|
||||
return {
|
||||
"mas": {
|
||||
"admin_api_base_url": "https://mas.example.org",
|
||||
"oauth_base_url": "https://oauth.mas.example.org",
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _setup_db(conn: sqlite3.Connection) -> None:
|
||||
conn.execute("CREATE TABLE access_tokens(user_id text, token text)")
|
||||
conn.execute(
|
||||
"CREATE TABLE users(name text, deactivated smallint, creation_ts bigint)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE TABLE guest_module_mas_users(mas_user_id text, user_id text, created_at_sec bigint)"
|
||||
)
|
||||
|
||||
@@ -7,17 +7,21 @@
|
||||
# Originally licensed under the Apache License, Version 2.0:
|
||||
# <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
|
||||
from typing import Tuple
|
||||
from unittest.mock import Mock
|
||||
|
||||
import aiounittest
|
||||
from parameterized import parameterized_class # type: ignore[import-untyped]
|
||||
from synapse.module_api import ProfileInfo, UserProfile
|
||||
from synapse.module_api.errors import ConfigError
|
||||
from synapse.types import UserID
|
||||
|
||||
from synapse_guest_module.config import GuestModuleConfig
|
||||
from synapse_guest_module.config import GuestModuleConfig, MasConfig
|
||||
from synapse_guest_module.guest_module import GuestModule
|
||||
from tests import create_module
|
||||
from tests import SQLiteStore, create_module, mas_config_override
|
||||
|
||||
|
||||
class GuestModuleTest(aiounittest.AsyncTestCase):
|
||||
class GuestModuleConfigTest(aiounittest.AsyncTestCase):
|
||||
async def test_parse_config_empty(self) -> None:
|
||||
config = GuestModule.parse_config({})
|
||||
|
||||
@@ -28,10 +32,11 @@ class GuestModuleTest(aiounittest.AsyncTestCase):
|
||||
display_name_suffix=" (Guest)",
|
||||
enable_user_reaper=True,
|
||||
user_expiration_seconds=24 * 60 * 60,
|
||||
mas=None,
|
||||
),
|
||||
)
|
||||
|
||||
async def test_parse_config_custom(self) -> None:
|
||||
async def test_parse_config_no_mas(self) -> None:
|
||||
config = GuestModule.parse_config(
|
||||
{
|
||||
"user_id_prefix": "tmp-",
|
||||
@@ -48,6 +53,35 @@ class GuestModuleTest(aiounittest.AsyncTestCase):
|
||||
display_name_suffix=" (Temporary)",
|
||||
enable_user_reaper=False,
|
||||
user_expiration_seconds=100,
|
||||
mas=None,
|
||||
),
|
||||
)
|
||||
|
||||
async def test_parse_config_mas(self) -> None:
|
||||
config = GuestModule.parse_config(
|
||||
{
|
||||
"mas": {
|
||||
"admin_api_base_url": "https://mas.example.org",
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
config,
|
||||
GuestModuleConfig(
|
||||
user_id_prefix="guest-",
|
||||
display_name_suffix=" (Guest)",
|
||||
enable_user_reaper=True,
|
||||
user_expiration_seconds=24 * 60 * 60,
|
||||
mas=MasConfig(
|
||||
admin_api_base_url="https://mas.example.org",
|
||||
oauth_base_url="https://mas.example.org",
|
||||
client_id="client-id",
|
||||
client_secret="client-secret",
|
||||
client_secret_filepath=None,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -91,8 +125,20 @@ class GuestModuleTest(aiounittest.AsyncTestCase):
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@parameterized_class(
|
||||
("variant", "config_override"),
|
||||
[
|
||||
("synapse", None),
|
||||
("mas", mas_config_override()),
|
||||
],
|
||||
)
|
||||
class GuestModuleRuntimeTest(aiounittest.AsyncTestCase):
|
||||
def create_module(self) -> Tuple[GuestModule, Mock, SQLiteStore]:
|
||||
return create_module(self.config_override)
|
||||
|
||||
async def test_profile_update_no_guest(self) -> None:
|
||||
module, module_api, _ = create_module()
|
||||
module, module_api, _ = self.create_module()
|
||||
|
||||
await module.profile_update(
|
||||
"@my-user:matrix.local",
|
||||
@@ -104,7 +150,7 @@ class GuestModuleTest(aiounittest.AsyncTestCase):
|
||||
module_api.set_displayname.assert_not_called()
|
||||
|
||||
async def test_profile_update_guest_keep(self) -> None:
|
||||
module, module_api, _ = create_module()
|
||||
module, module_api, _ = self.create_module()
|
||||
|
||||
await module.profile_update(
|
||||
"@guest-asdf:matrix.local",
|
||||
@@ -116,7 +162,7 @@ class GuestModuleTest(aiounittest.AsyncTestCase):
|
||||
module_api.set_displayname.assert_not_called()
|
||||
|
||||
async def test_profile_update_guest_add_and_trim(self) -> None:
|
||||
module, module_api, _ = create_module()
|
||||
module, module_api, _ = self.create_module()
|
||||
|
||||
await module.profile_update(
|
||||
"@guest-asdf:matrix.local",
|
||||
@@ -131,7 +177,7 @@ class GuestModuleTest(aiounittest.AsyncTestCase):
|
||||
)
|
||||
|
||||
async def test_callback_user_may_create_room_no_guest(self) -> None:
|
||||
module, _, _ = create_module()
|
||||
module, _, _ = self.create_module()
|
||||
|
||||
allow = await module.callback_user_may_create_room(
|
||||
"@my-user:matrix.local",
|
||||
@@ -140,7 +186,7 @@ class GuestModuleTest(aiounittest.AsyncTestCase):
|
||||
self.assertTrue(allow)
|
||||
|
||||
async def test_callback_user_may_create_room_guest(self) -> None:
|
||||
module, _, _ = create_module()
|
||||
module, _, _ = self.create_module()
|
||||
|
||||
allow = await module.callback_user_may_create_room(
|
||||
"@guest-asdf:matrix.local",
|
||||
@@ -149,7 +195,7 @@ class GuestModuleTest(aiounittest.AsyncTestCase):
|
||||
self.assertFalse(allow)
|
||||
|
||||
async def test_callback_user_may_invite_no_guest(self) -> None:
|
||||
module, _, _ = create_module()
|
||||
module, _, _ = self.create_module()
|
||||
|
||||
allow = await module.callback_user_may_invite(
|
||||
"@my-user:matrix.local",
|
||||
@@ -160,7 +206,7 @@ class GuestModuleTest(aiounittest.AsyncTestCase):
|
||||
self.assertTrue(allow)
|
||||
|
||||
async def test_callback_user_may_invite_guest(self) -> None:
|
||||
module, _, _ = create_module()
|
||||
module, _, _ = self.create_module()
|
||||
|
||||
allow = await module.callback_user_may_invite(
|
||||
"@guest-asdf:matrix.local",
|
||||
@@ -171,7 +217,7 @@ class GuestModuleTest(aiounittest.AsyncTestCase):
|
||||
self.assertFalse(allow)
|
||||
|
||||
async def test_callback_check_username_for_spam_no_guest(self) -> None:
|
||||
module, _, _ = create_module()
|
||||
module, _, _ = self.create_module()
|
||||
|
||||
allow = await module.callback_check_username_for_spam(
|
||||
UserProfile(
|
||||
@@ -184,7 +230,7 @@ class GuestModuleTest(aiounittest.AsyncTestCase):
|
||||
self.assertFalse(allow)
|
||||
|
||||
async def test_callback_check_username_for_spam_guest(self) -> None:
|
||||
module, _, _ = create_module()
|
||||
module, _, _ = self.create_module()
|
||||
|
||||
allow = await module.callback_check_username_for_spam(
|
||||
UserProfile(
|
||||
|
||||
@@ -8,19 +8,32 @@
|
||||
# <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
|
||||
import io
|
||||
from typing import cast
|
||||
from unittest.mock import ANY
|
||||
from typing import Tuple, cast
|
||||
from unittest.mock import ANY, Mock
|
||||
|
||||
import aiounittest
|
||||
from parameterized import parameterized_class # type: ignore[import-untyped]
|
||||
from twisted.web.server import Request
|
||||
from twisted.web.test.requesthelper import DummyRequest
|
||||
|
||||
from tests import create_module, make_awaitable
|
||||
from synapse_guest_module import GuestModule
|
||||
from synapse_guest_module.mas_admin_client import MasAdminClient
|
||||
from tests import SQLiteStore, create_module, make_awaitable, mas_config_override
|
||||
|
||||
|
||||
@parameterized_class(
|
||||
("variant", "config_override"),
|
||||
[
|
||||
("synapse", None),
|
||||
("mas", mas_config_override()),
|
||||
],
|
||||
)
|
||||
class GuestUserReaperTest(aiounittest.AsyncTestCase):
|
||||
def create_module(self) -> Tuple[GuestModule, Mock, SQLiteStore]:
|
||||
return create_module(self.config_override)
|
||||
|
||||
async def test_async_render_POST_missing_displayname(self) -> None:
|
||||
module, _, _ = create_module()
|
||||
module, _, _ = self.create_module()
|
||||
|
||||
request = cast(Request, DummyRequest([]))
|
||||
request.content = io.BytesIO(b"{}")
|
||||
@@ -33,7 +46,7 @@ class GuestUserReaperTest(aiounittest.AsyncTestCase):
|
||||
)
|
||||
|
||||
async def test_async_render_POST_empty_displayname(self) -> None:
|
||||
module, _, _ = create_module()
|
||||
module, _, _ = self.create_module()
|
||||
|
||||
request = cast(Request, DummyRequest([]))
|
||||
request.content = io.BytesIO(b'{"displayname":" "}')
|
||||
@@ -46,7 +59,7 @@ class GuestUserReaperTest(aiounittest.AsyncTestCase):
|
||||
)
|
||||
|
||||
async def test_async_render_POST_no_free_username(self) -> None:
|
||||
module, module_api, _ = create_module()
|
||||
module, module_api, _ = self.create_module()
|
||||
|
||||
request = cast(Request, DummyRequest([]))
|
||||
request.content = io.BytesIO(b'{"displayname":"My Name"}')
|
||||
@@ -63,24 +76,61 @@ class GuestUserReaperTest(aiounittest.AsyncTestCase):
|
||||
self.assertEqual(module_api.check_user_exists.call_count, 10)
|
||||
|
||||
async def test_async_render_POST_success(self) -> None:
|
||||
module, module_api, _ = create_module()
|
||||
module, module_api, _ = self.create_module()
|
||||
|
||||
request = cast(Request, DummyRequest([]))
|
||||
request.content = io.BytesIO(b'{"displayname":"My Name "}')
|
||||
|
||||
if self.config_override is not None:
|
||||
module.registration_servlet._mas_admin_client._generate_device_id = ( # type: ignore[method-assign,union-attr]
|
||||
lambda: "MASDEVICE123"
|
||||
)
|
||||
|
||||
# `make_awaitable` is not needed here as both methods are already `AsyncMock`.
|
||||
module_api.http_client.post_urlencoded_get_json.return_value = {
|
||||
"access_token": "mas_admin_token"
|
||||
}
|
||||
module_api.http_client.post_json_get_json.side_effect = [
|
||||
{"data": {"id": "mas-user-id"}},
|
||||
{
|
||||
"data": {
|
||||
"id": "MASDEVICE123",
|
||||
"attributes": {"access_token": "mas_access_token"},
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
status, response = await module.registration_servlet._async_render_POST(request)
|
||||
|
||||
self.assertEqual(status, 201)
|
||||
|
||||
self.assertRegex(response.pop("userId"), r"^@guest-[A-Za-z0-9]+:matrix.local$")
|
||||
self.assertDictEqual(
|
||||
response,
|
||||
{
|
||||
"accessToken": "syn_registered_token",
|
||||
"deviceId": "DEVICEID",
|
||||
"homeserverUrl": "https://matrix.local:1234/",
|
||||
# "userId" was already checked by self.assertRegex and was removed from the object
|
||||
},
|
||||
)
|
||||
if self.config_override is None:
|
||||
self.assertDictEqual(
|
||||
response,
|
||||
{
|
||||
"accessToken": "syn_registered_token",
|
||||
"deviceId": "DEVICEID",
|
||||
"homeserverUrl": "https://matrix.local:1234/",
|
||||
# "userId" was already checked by self.assertRegex and was removed from the object
|
||||
},
|
||||
)
|
||||
module_api.register_user.assert_called_with(ANY, "My Name (Guest)")
|
||||
else:
|
||||
self.assertDictEqual(
|
||||
response,
|
||||
{
|
||||
"accessToken": "mas_access_token",
|
||||
"deviceId": "MASDEVICE123",
|
||||
"homeserverUrl": "https://matrix.local:1234/",
|
||||
# "userId" was already checked by self.assertRegex and was removed from the object
|
||||
},
|
||||
)
|
||||
|
||||
module_api.register_user.assert_called_with(ANY, "My Name (Guest)")
|
||||
|
||||
class MasAdminClientTest(aiounittest.AsyncTestCase):
|
||||
async def test_generate_device_id_format(self) -> None:
|
||||
device_id = MasAdminClient._generate_device_id()
|
||||
|
||||
self.assertGreaterEqual(len(device_id), 10)
|
||||
self.assertRegex(device_id, r"^[A-Za-z0-9-]+$")
|
||||
|
||||
@@ -121,3 +121,46 @@ class GuestUserReaperTest(aiounittest.AsyncTestCase):
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async def test_deactivate_expired_mas_users_success(self) -> None:
|
||||
module, module_api, store = create_module(
|
||||
{
|
||||
"mas": {
|
||||
"admin_api_base_url": "https://mas.example.org",
|
||||
"oauth_base_url": "https://oauth.mas.example.org",
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
now = int(time.time())
|
||||
store.conn.executemany(
|
||||
"INSERT INTO guest_module_mas_users VALUES (?, ?, ?)",
|
||||
[
|
||||
["mas-old-1", "@old-1:localhost", 0],
|
||||
["mas-active", "@active:localhost", now],
|
||||
],
|
||||
)
|
||||
|
||||
# These two methods are `AsyncMock`s, so no need to use `make_awaitable`.`
|
||||
token_response = {"access_token": "mas_admin_token"}
|
||||
module_api.http_client.post_urlencoded_get_json.return_value = token_response
|
||||
module_api.http_client.post_json_get_json.return_value = {}
|
||||
|
||||
await module.reaper.deactivate_expired_guest_users()
|
||||
|
||||
deactivate_call = call(
|
||||
uri="https://mas.example.org/api/admin/v1/users/mas-old-1/deactivate",
|
||||
post_json={"skip_erase": True},
|
||||
headers={"Authorization": ["Bearer mas_admin_token"]},
|
||||
)
|
||||
self.assertIn(
|
||||
deactivate_call,
|
||||
module_api.http_client.post_json_get_json.await_args_list,
|
||||
)
|
||||
|
||||
remaining_users = store.conn.execute(
|
||||
"SELECT mas_user_id, user_id FROM guest_module_mas_users"
|
||||
).fetchall()
|
||||
self.assertEqual(remaining_users, [("mas-active", "@active:localhost")])
|
||||
|
||||
Reference in New Issue
Block a user