This attempts to be a direct port of https://github.com/matrix-org/synapse-dinsic/pull/74 to mainline. There was some fiddling required to deal with the changes that have been made to mainline since (mainly dealing with the split of `RegistrationWorkerStore` from `RegistrationStore`, and the changes made to `self.make_request` in test code).tags/v1.33.0rc1
@@ -85,6 +85,29 @@ for example: | |||
wget https://packages.matrix.org/debian/pool/main/m/matrix-synapse-py3/matrix-synapse-py3_1.3.0+stretch1_amd64.deb | |||
dpkg -i matrix-synapse-py3_1.3.0+stretch1_amd64.deb | |||
Upgrading to v1.33.0 | |||
==================== | |||
Account Validity HTML templates can now display a user's expiration date | |||
------------------------------------------------------------------------ | |||
This may affect you if you have enabled the account validity feature, and have made use of a | |||
custom HTML template specified by the ``account_validity.template_dir`` or ``account_validity.account_renewed_html_path`` | |||
Synapse config options. | |||
The template can now accept an ``expiration_ts`` variable, which represents the unix timestamp in milliseconds for the | |||
future date of which their account has been renewed until. See the | |||
`default template <https://github.com/matrix-org/synapse/blob/release-v1.33.0/synapse/res/templates/account_renewed.html>`_ | |||
for an example of usage. | |||
ALso note that a new HTML template, ``account_previously_renewed.html``, has been added. This is is shown to users | |||
when they attempt to renew their account with a valid renewal token that has already been used before. The default | |||
template contents can been found | |||
`here <https://github.com/matrix-org/synapse/blob/release-v1.33.0/synapse/res/templates/account_previously_renewed.html>`_, | |||
and can also accept an ``expiration_ts`` variable. This template replaces the error message users would previously see | |||
upon attempting to use a valid renewal token more than once. | |||
Upgrading to v1.32.0 | |||
==================== | |||
@@ -0,0 +1 @@ | |||
Don't return an error when a user attempts to renew their account multiple times with the same token. Instead, state when their account is set to expire. This change concerns the optional account validity feature. |
@@ -1175,69 +1175,6 @@ url_preview_accept_language: | |||
# | |||
#enable_registration: false | |||
# Optional account validity configuration. This allows for accounts to be denied | |||
# any request after a given period. | |||
# | |||
# Once this feature is enabled, Synapse will look for registered users without an | |||
# expiration date at startup and will add one to every account it found using the | |||
# current settings at that time. | |||
# This means that, if a validity period is set, and Synapse is restarted (it will | |||
# then derive an expiration date from the current validity period), and some time | |||
# after that the validity period changes and Synapse is restarted, the users' | |||
# expiration dates won't be updated unless their account is manually renewed. This | |||
# date will be randomly selected within a range [now + period - d ; now + period], | |||
# where d is equal to 10% of the validity period. | |||
# | |||
account_validity: | |||
# The account validity feature is disabled by default. Uncomment the | |||
# following line to enable it. | |||
# | |||
#enabled: true | |||
# The period after which an account is valid after its registration. When | |||
# renewing the account, its validity period will be extended by this amount | |||
# of time. This parameter is required when using the account validity | |||
# feature. | |||
# | |||
#period: 6w | |||
# The amount of time before an account's expiry date at which Synapse will | |||
# send an email to the account's email address with a renewal link. By | |||
# default, no such emails are sent. | |||
# | |||
# If you enable this setting, you will also need to fill out the 'email' and | |||
# 'public_baseurl' configuration sections. | |||
# | |||
#renew_at: 1w | |||
# The subject of the email sent out with the renewal link. '%(app)s' can be | |||
# used as a placeholder for the 'app_name' parameter from the 'email' | |||
# section. | |||
# | |||
# Note that the placeholder must be written '%(app)s', including the | |||
# trailing 's'. | |||
# | |||
# If this is not set, a default value is used. | |||
# | |||
#renew_email_subject: "Renew your %(app)s account" | |||
# Directory in which Synapse will try to find templates for the HTML files to | |||
# serve to the user when trying to renew an account. If not set, default | |||
# templates from within the Synapse package will be used. | |||
# | |||
#template_dir: "res/templates" | |||
# File within 'template_dir' giving the HTML to be displayed to the user after | |||
# they successfully renewed their account. If not set, default text is used. | |||
# | |||
#account_renewed_html_path: "account_renewed.html" | |||
# File within 'template_dir' giving the HTML to be displayed when the user | |||
# tries to renew an account with an invalid renewal token. If not set, | |||
# default text is used. | |||
# | |||
#invalid_token_html_path: "invalid_token.html" | |||
# Time that a user's session remains valid for, after they log in. | |||
# | |||
# Note that this is not currently compatible with guest logins. | |||
@@ -1432,6 +1369,91 @@ account_threepid_delegates: | |||
#auto_join_rooms_for_guests: false | |||
## Account Validity ## | |||
# Optional account validity configuration. This allows for accounts to be denied | |||
# any request after a given period. | |||
# | |||
# Once this feature is enabled, Synapse will look for registered users without an | |||
# expiration date at startup and will add one to every account it found using the | |||
# current settings at that time. | |||
# This means that, if a validity period is set, and Synapse is restarted (it will | |||
# then derive an expiration date from the current validity period), and some time | |||
# after that the validity period changes and Synapse is restarted, the users' | |||
# expiration dates won't be updated unless their account is manually renewed. This | |||
# date will be randomly selected within a range [now + period - d ; now + period], | |||
# where d is equal to 10% of the validity period. | |||
# | |||
account_validity: | |||
# The account validity feature is disabled by default. Uncomment the | |||
# following line to enable it. | |||
# | |||
#enabled: true | |||
# The period after which an account is valid after its registration. When | |||
# renewing the account, its validity period will be extended by this amount | |||
# of time. This parameter is required when using the account validity | |||
# feature. | |||
# | |||
#period: 6w | |||
# The amount of time before an account's expiry date at which Synapse will | |||
# send an email to the account's email address with a renewal link. By | |||
# default, no such emails are sent. | |||
# | |||
# If you enable this setting, you will also need to fill out the 'email' and | |||
# 'public_baseurl' configuration sections. | |||
# | |||
#renew_at: 1w | |||
# The subject of the email sent out with the renewal link. '%(app)s' can be | |||
# used as a placeholder for the 'app_name' parameter from the 'email' | |||
# section. | |||
# | |||
# Note that the placeholder must be written '%(app)s', including the | |||
# trailing 's'. | |||
# | |||
# If this is not set, a default value is used. | |||
# | |||
#renew_email_subject: "Renew your %(app)s account" | |||
# Directory in which Synapse will try to find templates for the HTML files to | |||
# serve to the user when trying to renew an account. If not set, default | |||
# templates from within the Synapse package will be used. | |||
# | |||
# The currently available templates are: | |||
# | |||
# * account_renewed.html: Displayed to the user after they have successfully | |||
# renewed their account. | |||
# | |||
# * account_previously_renewed.html: Displayed to the user if they attempt to | |||
# renew their account with a token that is valid, but that has already | |||
# been used. In this case the account is not renewed again. | |||
# | |||
# * invalid_token.html: Displayed to the user when they try to renew an account | |||
# with an unknown or invalid renewal token. | |||
# | |||
# See https://github.com/matrix-org/synapse/tree/master/synapse/res/templates for | |||
# default template contents. | |||
# | |||
# The file name of some of these templates can be configured below for legacy | |||
# reasons. | |||
# | |||
#template_dir: "res/templates" | |||
# A custom file name for the 'account_renewed.html' template. | |||
# | |||
# If not set, the file is assumed to be named "account_renewed.html". | |||
# | |||
#account_renewed_html_path: "account_renewed.html" | |||
# A custom file name for the 'invalid_token.html' template. | |||
# | |||
# If not set, the file is assumed to be named "invalid_token.html". | |||
# | |||
#invalid_token_html_path: "invalid_token.html" | |||
## Metrics ### | |||
# Enable collection and rendering of performance metrics | |||
@@ -79,7 +79,9 @@ class Auth: | |||
self._auth_blocking = AuthBlocking(self.hs) | |||
self._account_validity = hs.config.account_validity | |||
self._account_validity_enabled = ( | |||
hs.config.account_validity.account_validity_enabled | |||
) | |||
self._track_appservice_user_ips = hs.config.track_appservice_user_ips | |||
self._macaroon_secret_key = hs.config.macaroon_secret_key | |||
@@ -222,7 +224,7 @@ class Auth: | |||
shadow_banned = user_info.shadow_banned | |||
# Deny the request if the user account has expired. | |||
if self._account_validity.enabled and not allow_expired: | |||
if self._account_validity_enabled and not allow_expired: | |||
if await self.store.is_account_expired( | |||
user_info.user_id, self.clock.time_msec() | |||
): | |||
@@ -1,6 +1,7 @@ | |||
from typing import Any, Iterable, List, Optional | |||
from synapse.config import ( | |||
account_validity, | |||
api, | |||
appservice, | |||
auth, | |||
@@ -59,6 +60,7 @@ class RootConfig: | |||
captcha: captcha.CaptchaConfig | |||
voip: voip.VoipConfig | |||
registration: registration.RegistrationConfig | |||
account_validity: account_validity.AccountValidityConfig | |||
metrics: metrics.MetricsConfig | |||
api: api.ApiConfig | |||
appservice: appservice.AppServiceConfig | |||
@@ -0,0 +1,165 @@ | |||
# -*- coding: utf-8 -*- | |||
# Copyright 2020 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. | |||
from synapse.config._base import Config, ConfigError | |||
class AccountValidityConfig(Config): | |||
section = "account_validity" | |||
def read_config(self, config, **kwargs): | |||
account_validity_config = config.get("account_validity") or {} | |||
self.account_validity_enabled = account_validity_config.get("enabled", False) | |||
self.account_validity_renew_by_email_enabled = ( | |||
"renew_at" in account_validity_config | |||
) | |||
if self.account_validity_enabled: | |||
if "period" in account_validity_config: | |||
self.account_validity_period = self.parse_duration( | |||
account_validity_config["period"] | |||
) | |||
else: | |||
raise ConfigError("'period' is required when using account validity") | |||
if "renew_at" in account_validity_config: | |||
self.account_validity_renew_at = self.parse_duration( | |||
account_validity_config["renew_at"] | |||
) | |||
if "renew_email_subject" in account_validity_config: | |||
self.account_validity_renew_email_subject = account_validity_config[ | |||
"renew_email_subject" | |||
] | |||
else: | |||
self.account_validity_renew_email_subject = "Renew your %(app)s account" | |||
self.account_validity_startup_job_max_delta = ( | |||
self.account_validity_period * 10.0 / 100.0 | |||
) | |||
if self.account_validity_renew_by_email_enabled: | |||
if not self.public_baseurl: | |||
raise ConfigError("Can't send renewal emails without 'public_baseurl'") | |||
# Load account validity templates. | |||
account_validity_template_dir = account_validity_config.get("template_dir") | |||
account_renewed_template_filename = account_validity_config.get( | |||
"account_renewed_html_path", "account_renewed.html" | |||
) | |||
invalid_token_template_filename = account_validity_config.get( | |||
"invalid_token_html_path", "invalid_token.html" | |||
) | |||
# Read and store template content | |||
( | |||
self.account_validity_account_renewed_template, | |||
self.account_validity_account_previously_renewed_template, | |||
self.account_validity_invalid_token_template, | |||
) = self.read_templates( | |||
[ | |||
account_renewed_template_filename, | |||
"account_previously_renewed.html", | |||
invalid_token_template_filename, | |||
], | |||
account_validity_template_dir, | |||
) | |||
def generate_config_section(self, **kwargs): | |||
return """\ | |||
## Account Validity ## | |||
# Optional account validity configuration. This allows for accounts to be denied | |||
# any request after a given period. | |||
# | |||
# Once this feature is enabled, Synapse will look for registered users without an | |||
# expiration date at startup and will add one to every account it found using the | |||
# current settings at that time. | |||
# This means that, if a validity period is set, and Synapse is restarted (it will | |||
# then derive an expiration date from the current validity period), and some time | |||
# after that the validity period changes and Synapse is restarted, the users' | |||
# expiration dates won't be updated unless their account is manually renewed. This | |||
# date will be randomly selected within a range [now + period - d ; now + period], | |||
# where d is equal to 10% of the validity period. | |||
# | |||
account_validity: | |||
# The account validity feature is disabled by default. Uncomment the | |||
# following line to enable it. | |||
# | |||
#enabled: true | |||
# The period after which an account is valid after its registration. When | |||
# renewing the account, its validity period will be extended by this amount | |||
# of time. This parameter is required when using the account validity | |||
# feature. | |||
# | |||
#period: 6w | |||
# The amount of time before an account's expiry date at which Synapse will | |||
# send an email to the account's email address with a renewal link. By | |||
# default, no such emails are sent. | |||
# | |||
# If you enable this setting, you will also need to fill out the 'email' and | |||
# 'public_baseurl' configuration sections. | |||
# | |||
#renew_at: 1w | |||
# The subject of the email sent out with the renewal link. '%(app)s' can be | |||
# used as a placeholder for the 'app_name' parameter from the 'email' | |||
# section. | |||
# | |||
# Note that the placeholder must be written '%(app)s', including the | |||
# trailing 's'. | |||
# | |||
# If this is not set, a default value is used. | |||
# | |||
#renew_email_subject: "Renew your %(app)s account" | |||
# Directory in which Synapse will try to find templates for the HTML files to | |||
# serve to the user when trying to renew an account. If not set, default | |||
# templates from within the Synapse package will be used. | |||
# | |||
# The currently available templates are: | |||
# | |||
# * account_renewed.html: Displayed to the user after they have successfully | |||
# renewed their account. | |||
# | |||
# * account_previously_renewed.html: Displayed to the user if they attempt to | |||
# renew their account with a token that is valid, but that has already | |||
# been used. In this case the account is not renewed again. | |||
# | |||
# * invalid_token.html: Displayed to the user when they try to renew an account | |||
# with an unknown or invalid renewal token. | |||
# | |||
# See https://github.com/matrix-org/synapse/tree/master/synapse/res/templates for | |||
# default template contents. | |||
# | |||
# The file name of some of these templates can be configured below for legacy | |||
# reasons. | |||
# | |||
#template_dir: "res/templates" | |||
# A custom file name for the 'account_renewed.html' template. | |||
# | |||
# If not set, the file is assumed to be named "account_renewed.html". | |||
# | |||
#account_renewed_html_path: "account_renewed.html" | |||
# A custom file name for the 'invalid_token.html' template. | |||
# | |||
# If not set, the file is assumed to be named "invalid_token.html". | |||
# | |||
#invalid_token_html_path: "invalid_token.html" | |||
""" |
@@ -299,7 +299,7 @@ class EmailConfig(Config): | |||
"client_base_url", email_config.get("riot_base_url", None) | |||
) | |||
if self.account_validity.renew_by_email_enabled: | |||
if self.account_validity_renew_by_email_enabled: | |||
expiry_template_html = email_config.get( | |||
"expiry_template_html", "notice_expiry.html" | |||
) | |||
@@ -12,8 +12,8 @@ | |||
# 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. | |||
from ._base import RootConfig | |||
from .account_validity import AccountValidityConfig | |||
from .api import ApiConfig | |||
from .appservice import AppServiceConfig | |||
from .auth import AuthConfig | |||
@@ -68,6 +68,7 @@ class HomeServerConfig(RootConfig): | |||
CaptchaConfig, | |||
VoipConfig, | |||
RegistrationConfig, | |||
AccountValidityConfig, | |||
MetricsConfig, | |||
ApiConfig, | |||
AppServiceConfig, | |||
@@ -12,74 +12,12 @@ | |||
# See the License for the specific language governing permissions and | |||
# limitations under the License. | |||
import os | |||
import pkg_resources | |||
from synapse.api.constants import RoomCreationPreset | |||
from synapse.config._base import Config, ConfigError | |||
from synapse.types import RoomAlias, UserID | |||
from synapse.util.stringutils import random_string_with_symbols, strtobool | |||
class AccountValidityConfig(Config): | |||
section = "accountvalidity" | |||
def __init__(self, config, synapse_config): | |||
if config is None: | |||
return | |||
super().__init__() | |||
self.enabled = config.get("enabled", False) | |||
self.renew_by_email_enabled = "renew_at" in config | |||
if self.enabled: | |||
if "period" in config: | |||
self.period = self.parse_duration(config["period"]) | |||
else: | |||
raise ConfigError("'period' is required when using account validity") | |||
if "renew_at" in config: | |||
self.renew_at = self.parse_duration(config["renew_at"]) | |||
if "renew_email_subject" in config: | |||
self.renew_email_subject = config["renew_email_subject"] | |||
else: | |||
self.renew_email_subject = "Renew your %(app)s account" | |||
self.startup_job_max_delta = self.period * 10.0 / 100.0 | |||
if self.renew_by_email_enabled: | |||
if "public_baseurl" not in synapse_config: | |||
raise ConfigError("Can't send renewal emails without 'public_baseurl'") | |||
template_dir = config.get("template_dir") | |||
if not template_dir: | |||
template_dir = pkg_resources.resource_filename("synapse", "res/templates") | |||
if "account_renewed_html_path" in config: | |||
file_path = os.path.join(template_dir, config["account_renewed_html_path"]) | |||
self.account_renewed_html_content = self.read_file( | |||
file_path, "account_validity.account_renewed_html_path" | |||
) | |||
else: | |||
self.account_renewed_html_content = ( | |||
"<html><body>Your account has been successfully renewed.</body><html>" | |||
) | |||
if "invalid_token_html_path" in config: | |||
file_path = os.path.join(template_dir, config["invalid_token_html_path"]) | |||
self.invalid_token_html_content = self.read_file( | |||
file_path, "account_validity.invalid_token_html_path" | |||
) | |||
else: | |||
self.invalid_token_html_content = ( | |||
"<html><body>Invalid renewal token.</body><html>" | |||
) | |||
class RegistrationConfig(Config): | |||
section = "registration" | |||
@@ -92,10 +30,6 @@ class RegistrationConfig(Config): | |||
str(config["disable_registration"]) | |||
) | |||
self.account_validity = AccountValidityConfig( | |||
config.get("account_validity") or {}, config | |||
) | |||
self.registrations_require_3pid = config.get("registrations_require_3pid", []) | |||
self.allowed_local_3pids = config.get("allowed_local_3pids", []) | |||
self.enable_3pid_lookup = config.get("enable_3pid_lookup", True) | |||
@@ -207,69 +141,6 @@ class RegistrationConfig(Config): | |||
# | |||
#enable_registration: false | |||
# Optional account validity configuration. This allows for accounts to be denied | |||
# any request after a given period. | |||
# | |||
# Once this feature is enabled, Synapse will look for registered users without an | |||
# expiration date at startup and will add one to every account it found using the | |||
# current settings at that time. | |||
# This means that, if a validity period is set, and Synapse is restarted (it will | |||
# then derive an expiration date from the current validity period), and some time | |||
# after that the validity period changes and Synapse is restarted, the users' | |||
# expiration dates won't be updated unless their account is manually renewed. This | |||
# date will be randomly selected within a range [now + period - d ; now + period], | |||
# where d is equal to 10%% of the validity period. | |||
# | |||
account_validity: | |||
# The account validity feature is disabled by default. Uncomment the | |||
# following line to enable it. | |||
# | |||
#enabled: true | |||
# The period after which an account is valid after its registration. When | |||
# renewing the account, its validity period will be extended by this amount | |||
# of time. This parameter is required when using the account validity | |||
# feature. | |||
# | |||
#period: 6w | |||
# The amount of time before an account's expiry date at which Synapse will | |||
# send an email to the account's email address with a renewal link. By | |||
# default, no such emails are sent. | |||
# | |||
# If you enable this setting, you will also need to fill out the 'email' and | |||
# 'public_baseurl' configuration sections. | |||
# | |||
#renew_at: 1w | |||
# The subject of the email sent out with the renewal link. '%%(app)s' can be | |||
# used as a placeholder for the 'app_name' parameter from the 'email' | |||
# section. | |||
# | |||
# Note that the placeholder must be written '%%(app)s', including the | |||
# trailing 's'. | |||
# | |||
# If this is not set, a default value is used. | |||
# | |||
#renew_email_subject: "Renew your %%(app)s account" | |||
# Directory in which Synapse will try to find templates for the HTML files to | |||
# serve to the user when trying to renew an account. If not set, default | |||
# templates from within the Synapse package will be used. | |||
# | |||
#template_dir: "res/templates" | |||
# File within 'template_dir' giving the HTML to be displayed to the user after | |||
# they successfully renewed their account. If not set, default text is used. | |||
# | |||
#account_renewed_html_path: "account_renewed.html" | |||
# File within 'template_dir' giving the HTML to be displayed when the user | |||
# tries to renew an account with an invalid renewal token. If not set, | |||
# default text is used. | |||
# | |||
#invalid_token_html_path: "invalid_token.html" | |||
# Time that a user's session remains valid for, after they log in. | |||
# | |||
# Note that this is not currently compatible with guest logins. | |||
@@ -17,7 +17,7 @@ import email.utils | |||
import logging | |||
from email.mime.multipart import MIMEMultipart | |||
from email.mime.text import MIMEText | |||
from typing import TYPE_CHECKING, List, Optional | |||
from typing import TYPE_CHECKING, List, Optional, Tuple | |||
from synapse.api.errors import StoreError, SynapseError | |||
from synapse.logging.context import make_deferred_yieldable | |||
@@ -39,28 +39,44 @@ class AccountValidityHandler: | |||
self.sendmail = self.hs.get_sendmail() | |||
self.clock = self.hs.get_clock() | |||
self._account_validity = self.hs.config.account_validity | |||
self._account_validity_enabled = ( | |||
hs.config.account_validity.account_validity_enabled | |||
) | |||
self._account_validity_renew_by_email_enabled = ( | |||
hs.config.account_validity.account_validity_renew_by_email_enabled | |||
) | |||
self._account_validity_period = None | |||
if self._account_validity_enabled: | |||
self._account_validity_period = ( | |||
hs.config.account_validity.account_validity_period | |||
) | |||
if ( | |||
self._account_validity.enabled | |||
and self._account_validity.renew_by_email_enabled | |||
self._account_validity_enabled | |||
and self._account_validity_renew_by_email_enabled | |||
): | |||
# Don't do email-specific configuration if renewal by email is disabled. | |||
self._template_html = self.config.account_validity_template_html | |||
self._template_text = self.config.account_validity_template_text | |||
self._template_html = ( | |||
hs.config.account_validity.account_validity_template_html | |||
) | |||
self._template_text = ( | |||
hs.config.account_validity.account_validity_template_text | |||
) | |||
account_validity_renew_email_subject = ( | |||
hs.config.account_validity.account_validity_renew_email_subject | |||
) | |||
try: | |||
app_name = self.hs.config.email_app_name | |||
app_name = hs.config.email_app_name | |||
self._subject = self._account_validity.renew_email_subject % { | |||
"app": app_name | |||
} | |||
self._subject = account_validity_renew_email_subject % {"app": app_name} | |||
self._from_string = self.hs.config.email_notif_from % {"app": app_name} | |||
self._from_string = hs.config.email_notif_from % {"app": app_name} | |||
except Exception: | |||
# If substitution failed, fall back to the bare strings. | |||
self._subject = self._account_validity.renew_email_subject | |||
self._from_string = self.hs.config.email_notif_from | |||
self._subject = account_validity_renew_email_subject | |||
self._from_string = hs.config.email_notif_from | |||
self._raw_from = email.utils.parseaddr(self._from_string)[1] | |||
@@ -220,50 +236,87 @@ class AccountValidityHandler: | |||
attempts += 1 | |||
raise StoreError(500, "Couldn't generate a unique string as refresh string.") | |||
async def renew_account(self, renewal_token: str) -> bool: | |||
async def renew_account(self, renewal_token: str) -> Tuple[bool, bool, int]: | |||
"""Renews the account attached to a given renewal token by pushing back the | |||
expiration date by the current validity period in the server's configuration. | |||
If it turns out that the token is valid but has already been used, then the | |||
token is considered stale. A token is stale if the 'token_used_ts_ms' db column | |||
is non-null. | |||
Args: | |||
renewal_token: Token sent with the renewal request. | |||
Returns: | |||
Whether the provided token is valid. | |||
A tuple containing: | |||
* A bool representing whether the token is valid and unused. | |||
* A bool which is `True` if the token is valid, but stale. | |||
* An int representing the user's expiry timestamp as milliseconds since the | |||
epoch, or 0 if the token was invalid. | |||
""" | |||
try: | |||
user_id = await self.store.get_user_from_renewal_token(renewal_token) | |||
( | |||
user_id, | |||
current_expiration_ts, | |||
token_used_ts, | |||
) = await self.store.get_user_from_renewal_token(renewal_token) | |||
except StoreError: | |||
return False | |||
return False, False, 0 | |||
# Check whether this token has already been used. | |||
if token_used_ts: | |||
logger.info( | |||
"User '%s' attempted to use previously used token '%s' to renew account", | |||
user_id, | |||
renewal_token, | |||
) | |||
return False, True, current_expiration_ts | |||
logger.debug("Renewing an account for user %s", user_id) | |||
await self.renew_account_for_user(user_id) | |||
return True | |||
# Renew the account. Pass the renewal_token here so that it is not cleared. | |||
# We want to keep the token around in case the user attempts to renew their | |||
# account with the same token twice (clicking the email link twice). | |||
# | |||
# In that case, the token will be accepted, but the account's expiration ts | |||
# will remain unchanged. | |||
new_expiration_ts = await self.renew_account_for_user( | |||
user_id, renewal_token=renewal_token | |||
) | |||
return True, False, new_expiration_ts | |||
async def renew_account_for_user( | |||
self, | |||
user_id: str, | |||
expiration_ts: Optional[int] = None, | |||
email_sent: bool = False, | |||
renewal_token: Optional[str] = None, | |||
) -> int: | |||
"""Renews the account attached to a given user by pushing back the | |||
expiration date by the current validity period in the server's | |||
configuration. | |||
Args: | |||
renewal_token: Token sent with the renewal request. | |||
user_id: The ID of the user to renew. | |||
expiration_ts: New expiration date. Defaults to now + validity period. | |||
email_sen: Whether an email has been sent for this validity period. | |||
Defaults to False. | |||
email_sent: Whether an email has been sent for this validity period. | |||
renewal_token: Token sent with the renewal request. The user's token | |||
will be cleared if this is None. | |||
Returns: | |||
New expiration date for this account, as a timestamp in | |||
milliseconds since epoch. | |||
""" | |||
now = self.clock.time_msec() | |||
if expiration_ts is None: | |||
expiration_ts = self.clock.time_msec() + self._account_validity.period | |||
expiration_ts = now + self._account_validity_period | |||
await self.store.set_account_validity_for_user( | |||
user_id=user_id, expiration_ts=expiration_ts, email_sent=email_sent | |||
user_id=user_id, | |||
expiration_ts=expiration_ts, | |||
email_sent=email_sent, | |||
renewal_token=renewal_token, | |||
token_used_ts=now, | |||
) | |||
return expiration_ts |
@@ -49,7 +49,9 @@ class DeactivateAccountHandler(BaseHandler): | |||
if hs.config.run_background_tasks: | |||
hs.get_reactor().callWhenRunning(self._start_user_parting) | |||
self._account_validity_enabled = hs.config.account_validity.enabled | |||
self._account_validity_enabled = ( | |||
hs.config.account_validity.account_validity_enabled | |||
) | |||
async def deactivate_account( | |||
self, | |||
@@ -62,7 +62,9 @@ class PusherPool: | |||
self.store = self.hs.get_datastore() | |||
self.clock = self.hs.get_clock() | |||
self._account_validity = hs.config.account_validity | |||
self._account_validity_enabled = ( | |||
hs.config.account_validity.account_validity_enabled | |||
) | |||
# We shard the handling of push notifications by user ID. | |||
self._pusher_shard_config = hs.config.push.pusher_shard_config | |||
@@ -236,7 +238,7 @@ class PusherPool: | |||
for u in users_affected: | |||
# Don't push if the user account has expired | |||
if self._account_validity.enabled: | |||
if self._account_validity_enabled: | |||
expired = await self.store.is_account_expired( | |||
u, self.clock.time_msec() | |||
) | |||
@@ -266,7 +268,7 @@ class PusherPool: | |||
for u in users_affected: | |||
# Don't push if the user account has expired | |||
if self._account_validity.enabled: | |||
if self._account_validity_enabled: | |||
expired = await self.store.is_account_expired( | |||
u, self.clock.time_msec() | |||
) | |||
@@ -0,0 +1 @@ | |||
<html><body>Your account is valid until {{ expiration_ts|format_ts("%d-%m-%Y") }}.</body><html> |
@@ -1 +1 @@ | |||
<html><body>Your account has been successfully renewed.</body><html> | |||
<html><body>Your account has been successfully renewed and is valid until {{ expiration_ts|format_ts("%d-%m-%Y") }}.</body><html> |
@@ -36,24 +36,40 @@ class AccountValidityRenewServlet(RestServlet): | |||
self.hs = hs | |||
self.account_activity_handler = hs.get_account_validity_handler() | |||
self.auth = hs.get_auth() | |||
self.success_html = hs.config.account_validity.account_renewed_html_content | |||
self.failure_html = hs.config.account_validity.invalid_token_html_content | |||
self.account_renewed_template = ( | |||
hs.config.account_validity.account_validity_account_renewed_template | |||
) | |||
self.account_previously_renewed_template = ( | |||
hs.config.account_validity.account_validity_account_previously_renewed_template | |||
) | |||
self.invalid_token_template = ( | |||
hs.config.account_validity.account_validity_invalid_token_template | |||
) | |||
async def on_GET(self, request): | |||
if b"token" not in request.args: | |||
raise SynapseError(400, "Missing renewal token") | |||
renewal_token = request.args[b"token"][0] | |||
token_valid = await self.account_activity_handler.renew_account( | |||
( | |||
token_valid, | |||
token_stale, | |||
expiration_ts, | |||
) = await self.account_activity_handler.renew_account( | |||
renewal_token.decode("utf8") | |||
) | |||
if token_valid: | |||
status_code = 200 | |||
response = self.success_html | |||
response = self.account_renewed_template.render(expiration_ts=expiration_ts) | |||
elif token_stale: | |||
status_code = 200 | |||
response = self.account_previously_renewed_template.render( | |||
expiration_ts=expiration_ts | |||
) | |||
else: | |||
status_code = 404 | |||
response = self.failure_html | |||
response = self.invalid_token_template.render(expiration_ts=expiration_ts) | |||
respond_with_html(request, status_code, response) | |||
@@ -71,10 +87,12 @@ class AccountValiditySendMailServlet(RestServlet): | |||
self.hs = hs | |||
self.account_activity_handler = hs.get_account_validity_handler() | |||
self.auth = hs.get_auth() | |||
self.account_validity = self.hs.config.account_validity | |||
self.account_validity_renew_by_email_enabled = ( | |||
hs.config.account_validity.account_validity_renew_by_email_enabled | |||
) | |||
async def on_POST(self, request): | |||
if not self.account_validity.renew_by_email_enabled: | |||
if not self.account_validity_renew_by_email_enabled: | |||
raise AuthError( | |||
403, "Account renewal via email is disabled on this server." | |||
) | |||
@@ -91,13 +91,25 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore): | |||
id_column=None, | |||
) | |||
self._account_validity = hs.config.account_validity | |||
if hs.config.run_background_tasks and self._account_validity.enabled: | |||
self._clock.call_later( | |||
0.0, | |||
self._set_expiration_date_when_missing, | |||
self._account_validity_enabled = ( | |||
hs.config.account_validity.account_validity_enabled | |||
) | |||
self._account_validity_period = None | |||
self._account_validity_startup_job_max_delta = None | |||
if self._account_validity_enabled: | |||
self._account_validity_period = ( | |||
hs.config.account_validity.account_validity_period | |||
) | |||
self._account_validity_startup_job_max_delta = ( | |||
hs.config.account_validity.account_validity_startup_job_max_delta | |||
) | |||
if hs.config.run_background_tasks: | |||
self._clock.call_later( | |||
0.0, | |||
self._set_expiration_date_when_missing, | |||
) | |||
# Create a background job for culling expired 3PID validity tokens | |||
if hs.config.run_background_tasks: | |||
self._clock.looping_call( | |||
@@ -194,6 +206,7 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore): | |||
expiration_ts: int, | |||
email_sent: bool, | |||
renewal_token: Optional[str] = None, | |||
token_used_ts: Optional[int] = None, | |||
) -> None: | |||
"""Updates the account validity properties of the given account, with the | |||
given values. | |||
@@ -207,6 +220,8 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore): | |||
period. | |||
renewal_token: Renewal token the user can use to extend the validity | |||
of their account. Defaults to no token. | |||
token_used_ts: A timestamp of when the current token was used to renew | |||
the account. | |||
""" | |||
def set_account_validity_for_user_txn(txn): | |||
@@ -218,6 +233,7 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore): | |||
"expiration_ts_ms": expiration_ts, | |||
"email_sent": email_sent, | |||
"renewal_token": renewal_token, | |||
"token_used_ts_ms": token_used_ts, | |||
}, | |||
) | |||
self._invalidate_cache_and_stream( | |||
@@ -231,7 +247,7 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore): | |||
async def set_renewal_token_for_user( | |||
self, user_id: str, renewal_token: str | |||
) -> None: | |||
"""Defines a renewal token for a given user. | |||
"""Defines a renewal token for a given user, and clears the token_used timestamp. | |||
Args: | |||
user_id: ID of the user to set the renewal token for. | |||
@@ -244,26 +260,40 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore): | |||
await self.db_pool.simple_update_one( | |||
table="account_validity", | |||
keyvalues={"user_id": user_id}, | |||
updatevalues={"renewal_token": renewal_token}, | |||
updatevalues={"renewal_token": renewal_token, "token_used_ts_ms": None}, | |||
desc="set_renewal_token_for_user", | |||
) | |||
async def get_user_from_renewal_token(self, renewal_token: str) -> str: | |||
"""Get a user ID from a renewal token. | |||
async def get_user_from_renewal_token( | |||
self, renewal_token: str | |||
) -> Tuple[str, int, Optional[int]]: | |||
"""Get a user ID and renewal status from a renewal token. | |||
Args: | |||
renewal_token: The renewal token to perform the lookup with. | |||
Returns: | |||
The ID of the user to which the token belongs. | |||
A tuple of containing the following values: | |||
* The ID of a user to which the token belongs. | |||
* An int representing the user's expiry timestamp as milliseconds since the | |||
epoch, or 0 if the token was invalid. | |||
* An optional int representing the timestamp of when the user renewed their | |||
account timestamp as milliseconds since the epoch. None if the account | |||
has not been renewed using the current token yet. | |||
""" | |||
return await self.db_pool.simple_select_one_onecol( | |||
ret_dict = await self.db_pool.simple_select_one( | |||
table="account_validity", | |||
keyvalues={"renewal_token": renewal_token}, | |||
retcol="user_id", | |||
retcols=["user_id", "expiration_ts_ms", "token_used_ts_ms"], | |||
desc="get_user_from_renewal_token", | |||
) | |||
return ( | |||
ret_dict["user_id"], | |||
ret_dict["expiration_ts_ms"], | |||
ret_dict["token_used_ts_ms"], | |||
) | |||
async def get_renewal_token_for_user(self, user_id: str) -> str: | |||
"""Get the renewal token associated with a given user ID. | |||
@@ -302,7 +332,7 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore): | |||
"get_users_expiring_soon", | |||
select_users_txn, | |||
self._clock.time_msec(), | |||
self.config.account_validity.renew_at, | |||
self.config.account_validity_renew_at, | |||
) | |||
async def set_renewal_mail_status(self, user_id: str, email_sent: bool) -> None: | |||
@@ -964,11 +994,11 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore): | |||
delta equal to 10% of the validity period. | |||
""" | |||
now_ms = self._clock.time_msec() | |||
expiration_ts = now_ms + self._account_validity.period | |||
expiration_ts = now_ms + self._account_validity_period | |||
if use_delta: | |||
expiration_ts = self.rand.randrange( | |||
expiration_ts - self._account_validity.startup_job_max_delta, | |||
expiration_ts - self._account_validity_startup_job_max_delta, | |||
expiration_ts, | |||
) | |||
@@ -1412,7 +1442,7 @@ class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore): | |||
except self.database_engine.module.IntegrityError: | |||
raise StoreError(400, "User ID already taken.", errcode=Codes.USER_IN_USE) | |||
if self._account_validity.enabled: | |||
if self._account_validity_enabled: | |||
self.set_expiration_date_for_user_txn(txn, user_id) | |||
if create_profile_with_displayname: | |||
@@ -0,0 +1,18 @@ | |||
/* Copyright 2020 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. | |||
*/ | |||
-- Track when users renew their account using the value of the 'renewal_token' column. | |||
-- This field should be set to NULL after a fresh token is generated. | |||
ALTER TABLE account_validity ADD token_used_ts_ms BIGINT; |
@@ -492,8 +492,8 @@ class AccountValidityRenewalByEmailTestCase(unittest.HomeserverTestCase): | |||
(user_id, tok) = self.create_user() | |||
# Move 6 days forward. This should trigger a renewal email to be sent. | |||
self.reactor.advance(datetime.timedelta(days=6).total_seconds()) | |||
# Move 5 days forward. This should trigger a renewal email to be sent. | |||
self.reactor.advance(datetime.timedelta(days=5).total_seconds()) | |||
self.assertEqual(len(self.email_attempts), 1) | |||
# Retrieving the URL from the email is too much pain for now, so we | |||
@@ -504,14 +504,32 @@ class AccountValidityRenewalByEmailTestCase(unittest.HomeserverTestCase): | |||
self.assertEquals(channel.result["code"], b"200", channel.result) | |||
# Check that we're getting HTML back. | |||
content_type = None | |||
for header in channel.result.get("headers", []): | |||
if header[0] == b"Content-Type": | |||
content_type = header[1] | |||
self.assertEqual(content_type, b"text/html; charset=utf-8", channel.result) | |||
content_type = channel.headers.getRawHeaders(b"Content-Type") | |||
self.assertEqual(content_type, [b"text/html; charset=utf-8"], channel.result) | |||
# Check that the HTML we're getting is the one we expect on a successful renewal. | |||
expected_html = self.hs.config.account_validity.account_renewed_html_content | |||
expiration_ts = self.get_success(self.store.get_expiration_ts_for_user(user_id)) | |||
expected_html = self.hs.config.account_validity.account_validity_account_renewed_template.render( | |||
expiration_ts=expiration_ts | |||
) | |||
self.assertEqual( | |||
channel.result["body"], expected_html.encode("utf8"), channel.result | |||
) | |||
# Move 1 day forward. Try to renew with the same token again. | |||
url = "/_matrix/client/unstable/account_validity/renew?token=%s" % renewal_token | |||
channel = self.make_request(b"GET", url) | |||
self.assertEquals(channel.result["code"], b"200", channel.result) | |||
# Check that we're getting HTML back. | |||
content_type = channel.headers.getRawHeaders(b"Content-Type") | |||
self.assertEqual(content_type, [b"text/html; charset=utf-8"], channel.result) | |||
# Check that the HTML we're getting is the one we expect when reusing a | |||
# token. The account expiration date should not have changed. | |||
expected_html = self.hs.config.account_validity.account_validity_account_previously_renewed_template.render( | |||
expiration_ts=expiration_ts | |||
) | |||
self.assertEqual( | |||
channel.result["body"], expected_html.encode("utf8"), channel.result | |||
) | |||
@@ -531,15 +549,14 @@ class AccountValidityRenewalByEmailTestCase(unittest.HomeserverTestCase): | |||
self.assertEquals(channel.result["code"], b"404", channel.result) | |||
# Check that we're getting HTML back. | |||
content_type = None | |||
for header in channel.result.get("headers", []): | |||
if header[0] == b"Content-Type": | |||
content_type = header[1] | |||
self.assertEqual(content_type, b"text/html; charset=utf-8", channel.result) | |||
content_type = channel.headers.getRawHeaders(b"Content-Type") | |||
self.assertEqual(content_type, [b"text/html; charset=utf-8"], channel.result) | |||
# Check that the HTML we're getting is the one we expect when using an | |||
# invalid/unknown token. | |||
expected_html = self.hs.config.account_validity.invalid_token_html_content | |||
expected_html = ( | |||
self.hs.config.account_validity.account_validity_invalid_token_template.render() | |||
) | |||
self.assertEqual( | |||
channel.result["body"], expected_html.encode("utf8"), channel.result | |||
) | |||
@@ -647,7 +664,12 @@ class AccountValidityBackgroundJobTestCase(unittest.HomeserverTestCase): | |||
config["account_validity"] = {"enabled": False} | |||
self.hs = self.setup_test_homeserver(config=config) | |||
self.hs.config.account_validity.period = self.validity_period | |||
# We need to set these directly, instead of in the homeserver config dict above. | |||
# This is due to account validity-related config options not being read by | |||
# Synapse when account_validity.enabled is False. | |||
self.hs.get_datastore()._account_validity_period = self.validity_period | |||
self.hs.get_datastore()._account_validity_startup_job_max_delta = self.max_delta | |||
self.store = self.hs.get_datastore() | |||