diff --git a/plinth/modules/matrixsynapse/forms.py b/plinth/modules/matrixsynapse/forms.py index 2f505d9eb..f780fb75f 100644 --- a/plinth/modules/matrixsynapse/forms.py +++ b/plinth/modules/matrixsynapse/forms.py @@ -10,6 +10,17 @@ from django.utils.translation import gettext_lazy as _ from plinth.modules.coturn.forms import turn_uris_validator from plinth.utils import format_lazy +_registration_verification_choices = [ + ('disabled', + _('Disabled. This could lead to adversaries registering many spam ' + 'accounts on your server with automated scripts.')), + ('token', + _('Require users creating a new account to use a registration token. A ' + 'token will be created automatically. Pass this token to your ' + 'potential new users. They will be asked for the token during ' + 'registration. (recommended)')), +] + class MatrixSynapseForm(forms.Form): enable_public_registration = forms.BooleanField( @@ -18,6 +29,11 @@ class MatrixSynapseForm(forms.Form): 'can register a new account on your Matrix server. Disable this ' 'if you only want existing users to be able to use it.')) + registration_verification = forms.ChoiceField( + label=_('Verification method for registration'), + choices=_registration_verification_choices, required=True, + widget=forms.RadioSelect) + enable_managed_turn = forms.BooleanField( label=_('Automatically manage audio/video call setup'), required=False, help_text=format_lazy( diff --git a/plinth/modules/matrixsynapse/privileged.py b/plinth/modules/matrixsynapse/privileged.py index 332b0a535..8aa6fa2a7 100644 --- a/plinth/modules/matrixsynapse/privileged.py +++ b/plinth/modules/matrixsynapse/privileged.py @@ -1,14 +1,18 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """Configure Matrix-Synapse server.""" +import hashlib +import hmac import json import os import pathlib -from typing import Optional +import shutil +from typing import Dict, List, Optional, Union +import requests import yaml -from plinth import action_utils +from plinth import action_utils, utils from plinth.actions import privileged CONF_DIR = "/etc/matrix-synapse/conf.d/" @@ -18,8 +22,12 @@ SERVER_NAME_PATH = CONF_DIR + 'server_name.yaml' STATIC_CONF_PATH = CONF_DIR + 'freedombox-static.yaml' LISTENERS_CONF_PATH = CONF_DIR + 'freedombox-listeners.yaml' REGISTRATION_CONF_PATH = CONF_DIR + 'freedombox-registration.yaml' +REGISTRATION_SECRET_PATH = CONF_DIR + 'freedombox-registration-secret.yaml' +ADMIN_ACCESS_TOKEN_PATH = CONF_DIR + 'freedombox-admin-access-token.txt' TURN_CONF_PATH = CONF_DIR + 'freedombox-turn.yaml' OVERRIDDEN_TURN_CONF_PATH = CONF_DIR + 'turn.yaml' +FREEDOMBOX_ADMIN_USERNAME = 'freedombox-admin' +ADMIN_API_BASE = 'http://localhost:8008/_synapse/admin/v1/' STATIC_CONFIG = { 'max_upload_size': @@ -81,6 +89,8 @@ def get_config(): if config.get('enable_registration_without_verification'): registration_verification = 'disabled' + elif config.get('registration_requires_token'): + registration_verification = 'token' else: registration_verification = None @@ -94,9 +104,14 @@ def get_config(): def set_config(public_registration: bool, registration_verification: Optional[str] = None): """Enable/disable public user registration.""" + if registration_verification == 'token': + _create_registration_token() + config = {'enable_registration': public_registration} if public_registration and registration_verification in ('disabled', None): config['enable_registration_without_verification'] = True + elif registration_verification == 'token': + config['registration_requires_token'] = True with open(REGISTRATION_CONF_PATH, 'w', encoding='utf-8') as reg_conf_file: yaml.dump(config, reg_conf_file) @@ -155,3 +170,172 @@ def fix_public_registrations(): and config['registration_verification'] is None): set_config(public_registration=True, registration_verification='disabled') + + +def _ensure_server_is_running(): + """Raise an exception if matrix-synapse server is not running.""" + if not action_utils.service_is_running('matrix-synapse'): + raise ProcessLookupError + + +def _get_registration_shared_secret() -> str: + """Ensure that server is set to use a shared secret for registration.""" + secret_file = pathlib.Path(REGISTRATION_SECRET_PATH) + if secret_file.exists(): + with secret_file.open() as file_handle: + return yaml.safe_load(file_handle)['registration_shared_secret'] + + secret = utils.generate_password() + # Ensure that file is readable by matrix-synapse user and root. + previous_umask = os.umask(0o077) + try: + secret_file.write_text(f'registration_shared_secret: "{secret}"\n') + finally: + os.umask(previous_umask) + + shutil.chown(secret_file, 'matrix-synapse', 'nogroup') + action_utils.service_try_restart('matrix-synapse') + return secret + + +def _parse_api_error(response): + """Raise exception if the response of API call was an error.""" + if response.get('errcode'): + raise ConnectionError(response['errcode'], response['error']) + + +def _get_access_token() -> str: + """Create a new freedombox-admin account and return its access token. + + For user registration Admin API, see: + https://matrix-org.github.io/synapse/latest/admin_api/register_api.html + """ + access_token_path = pathlib.Path(ADMIN_ACCESS_TOKEN_PATH) + if access_token_path.exists(): + return access_token_path.read_text().strip() + + shared_secret = _get_registration_shared_secret() + nonce = _get_nonce() + username = FREEDOMBOX_ADMIN_USERNAME + # No need to store password, we will use the access token. + password = utils.generate_password() + mac = _generate_mac(shared_secret, nonce, username, password, True) + data = { + 'nonce': nonce, + 'username': username, + 'displayname': 'FreedomBox Admin', + 'password': password, + 'admin': True, + 'mac': mac + } + request = requests.post(ADMIN_API_BASE + 'register', json=data) + response = request.json() + _parse_api_error(response) + + # Ensure that file is only readable by root user. + previous_umask = os.umask(0o077) + try: + access_token_path.write_text(response['access_token']) + finally: + os.umask(previous_umask) + + return response['access_token'] + + +@privileged +def list_registration_tokens() -> List[Dict[str, Optional[Union[str, int]]]]: + """Return the current list of registration tokens.""" + if not action_utils.service_is_running('matrix-synapse'): + return [] + + access_token = _get_access_token() + return _list_registration_tokens(access_token) + + +def _get_headers(access_token: str): + """Return the common HTTP headers needed for synapse admin API. + + For details on authorization to the Admin API, see: + https://matrix-org.github.io/synapse/latest/usage/administration/admin_api/index.html + """ + return {'Authorization': f'Bearer {access_token}'} + + +def _list_registration_tokens( + access_token: str) -> List[Dict[str, Optional[Union[str, int]]]]: + """Use Admin API to fetch the list of registration tokens. + + For details on registration tokens API, see: + https://matrix-org.github.io/synapse/latest/usage/administration/admin_api/registration_tokens.html + """ + request = requests.get(ADMIN_API_BASE + 'registration_tokens', + headers=_get_headers(access_token)) + response = request.json() + _parse_api_error(response) + return response['registration_tokens'] + + +def _create_registration_token(): + """Make sure that at least one registration token is created. + + For details on registration tokens API, see: + https://matrix-org.github.io/synapse/latest/usage/administration/admin_api/registration_tokens.html + """ + _ensure_server_is_running() + + access_token = _get_access_token() + tokens = _list_registration_tokens(access_token) + for token in tokens: + if token['uses_allowed'] is None and token['expiry_time'] is None: + # A token with unlimited uses and for unlimited time already + # exists. + return + + if not tokens: + # API can generate a token for us but it includes special chars which + # is awkward to deal with for users. + token = utils.generate_password(size=12) + request = requests.post(ADMIN_API_BASE + 'registration_tokens/new', + headers=_get_headers(access_token), + json={'token': token}) + response = request.json() + _parse_api_error(response) + + +def _get_nonce() -> str: + """Make a preliminary registration request to get a nonce. + + Nonce must be returned to the server during actual registration request. It + is used in computation of MAC and prevents replay attacks. + https://matrix-org.github.io/synapse/latest/admin_api/register_api.html + """ + request = requests.get(ADMIN_API_BASE + 'register') + response = request.json() + _parse_api_error(response) + return response['nonce'] + + +def _generate_mac(shared_secret: str, nonce: str, user: str, password: str, + admin: bool = False, user_type=None) -> str: + """Generate MAC using HMAC-SHA1 algorithm. + + For information on how to encode the data for MAC computation, see: + https://matrix-org.github.io/synapse/latest/admin_api/register_api.html + """ + mac = hmac.new( + key=shared_secret.encode('utf8'), + digestmod=hashlib.sha1, + ) + + mac.update(nonce.encode('utf8')) + mac.update(b'\x00') + mac.update(user.encode('utf8')) + mac.update(b'\x00') + mac.update(password.encode('utf8')) + mac.update(b'\x00') + mac.update(b'admin' if admin else b'notadmin') + if user_type: + mac.update(b'\x00') + mac.update(user_type.encode('utf8')) + + return mac.hexdigest() diff --git a/plinth/modules/matrixsynapse/templates/matrix-synapse.html b/plinth/modules/matrixsynapse/templates/matrix-synapse.html index 226dc82f8..be4d47331 100644 --- a/plinth/modules/matrixsynapse/templates/matrix-synapse.html +++ b/plinth/modules/matrixsynapse/templates/matrix-synapse.html @@ -24,6 +24,50 @@ enabled. {% endblocktrans %}
+ {% if config.public_registration and config.registration_verification == 'token' and registration_tokens %} ++ {% blocktrans trimmed %} + New users must use one of the following tokens for verification during + account registration: + {% endblocktrans %} +
+| {% trans "Registration Token" %} | +{% trans "Uses Allowed" %} | +{% trans "Pending Registrations" %} | +{% trans "Completed Registrations" %} | +{% trans "Expiry Time" %} | +
|---|---|---|---|---|
| {{ token.token }} | ++ {% if token.uses_allowed is None %} + {% trans "Unlimited" %} + {% else %} + {{ token.uses_allowed }} + {% endif %} + | +{{ token.pending }} | +{{ token.completed }} | ++ {% if token.expiry_time %} + {{ token.expiry_time|date:"DATETIME_FORMAT" }} + {% else %} + {% trans "None" %} + {% endif %} + | +