Port "Allow users to click account renewal links multiple times without hitting an 'Invalid Token' page #74" from synapse-dinsic (#9832)

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).
This commit is contained in:
Andrew Morgan 2021-04-19 19:16:34 +01:00 committed by GitHub
parent e694a598f8
commit 71f0623de9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 496 additions and 263 deletions

View file

@ -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 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 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 Upgrading to v1.32.0
==================== ====================

1
changelog.d/9832.feature Normal file
View file

@ -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.

View file

@ -1175,69 +1175,6 @@ url_preview_accept_language:
# #
#enable_registration: false #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. # Time that a user's session remains valid for, after they log in.
# #
# Note that this is not currently compatible with guest logins. # Note that this is not currently compatible with guest logins.
@ -1432,6 +1369,91 @@ account_threepid_delegates:
#auto_join_rooms_for_guests: false #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 ### ## Metrics ###
# Enable collection and rendering of performance metrics # Enable collection and rendering of performance metrics

View file

@ -79,7 +79,9 @@ class Auth:
self._auth_blocking = AuthBlocking(self.hs) 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._track_appservice_user_ips = hs.config.track_appservice_user_ips
self._macaroon_secret_key = hs.config.macaroon_secret_key self._macaroon_secret_key = hs.config.macaroon_secret_key
@ -222,7 +224,7 @@ class Auth:
shadow_banned = user_info.shadow_banned shadow_banned = user_info.shadow_banned
# Deny the request if the user account has expired. # 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( if await self.store.is_account_expired(
user_info.user_id, self.clock.time_msec() user_info.user_id, self.clock.time_msec()
): ):

View file

@ -1,6 +1,7 @@
from typing import Any, Iterable, List, Optional from typing import Any, Iterable, List, Optional
from synapse.config import ( from synapse.config import (
account_validity,
api, api,
appservice, appservice,
auth, auth,
@ -59,6 +60,7 @@ class RootConfig:
captcha: captcha.CaptchaConfig captcha: captcha.CaptchaConfig
voip: voip.VoipConfig voip: voip.VoipConfig
registration: registration.RegistrationConfig registration: registration.RegistrationConfig
account_validity: account_validity.AccountValidityConfig
metrics: metrics.MetricsConfig metrics: metrics.MetricsConfig
api: api.ApiConfig api: api.ApiConfig
appservice: appservice.AppServiceConfig appservice: appservice.AppServiceConfig

View file

@ -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"
"""

View file

@ -299,7 +299,7 @@ class EmailConfig(Config):
"client_base_url", email_config.get("riot_base_url", None) "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 = email_config.get(
"expiry_template_html", "notice_expiry.html" "expiry_template_html", "notice_expiry.html"
) )

View file

@ -12,8 +12,8 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from ._base import RootConfig from ._base import RootConfig
from .account_validity import AccountValidityConfig
from .api import ApiConfig from .api import ApiConfig
from .appservice import AppServiceConfig from .appservice import AppServiceConfig
from .auth import AuthConfig from .auth import AuthConfig
@ -68,6 +68,7 @@ class HomeServerConfig(RootConfig):
CaptchaConfig, CaptchaConfig,
VoipConfig, VoipConfig,
RegistrationConfig, RegistrationConfig,
AccountValidityConfig,
MetricsConfig, MetricsConfig,
ApiConfig, ApiConfig,
AppServiceConfig, AppServiceConfig,

View file

@ -12,74 +12,12 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import os
import pkg_resources
from synapse.api.constants import RoomCreationPreset from synapse.api.constants import RoomCreationPreset
from synapse.config._base import Config, ConfigError from synapse.config._base import Config, ConfigError
from synapse.types import RoomAlias, UserID from synapse.types import RoomAlias, UserID
from synapse.util.stringutils import random_string_with_symbols, strtobool 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): class RegistrationConfig(Config):
section = "registration" section = "registration"
@ -92,10 +30,6 @@ class RegistrationConfig(Config):
str(config["disable_registration"]) str(config["disable_registration"])
) )
self.account_validity = AccountValidityConfig(
config.get("account_validity") or {}, config
)
self.registrations_require_3pid = config.get("registrations_require_3pid", []) self.registrations_require_3pid = config.get("registrations_require_3pid", [])
self.allowed_local_3pids = config.get("allowed_local_3pids", []) self.allowed_local_3pids = config.get("allowed_local_3pids", [])
self.enable_3pid_lookup = config.get("enable_3pid_lookup", True) self.enable_3pid_lookup = config.get("enable_3pid_lookup", True)
@ -207,69 +141,6 @@ class RegistrationConfig(Config):
# #
#enable_registration: false #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. # Time that a user's session remains valid for, after they log in.
# #
# Note that this is not currently compatible with guest logins. # Note that this is not currently compatible with guest logins.

View file

@ -17,7 +17,7 @@ import email.utils
import logging import logging
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText 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.api.errors import StoreError, SynapseError
from synapse.logging.context import make_deferred_yieldable from synapse.logging.context import make_deferred_yieldable
@ -39,28 +39,44 @@ class AccountValidityHandler:
self.sendmail = self.hs.get_sendmail() self.sendmail = self.hs.get_sendmail()
self.clock = self.hs.get_clock() 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 ( if (
self._account_validity.enabled self._account_validity_enabled
and self._account_validity.renew_by_email_enabled and self._account_validity_renew_by_email_enabled
): ):
# Don't do email-specific configuration if renewal by email is disabled. # Don't do email-specific configuration if renewal by email is disabled.
self._template_html = self.config.account_validity_template_html self._template_html = (
self._template_text = self.config.account_validity_template_text 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: try:
app_name = self.hs.config.email_app_name app_name = hs.config.email_app_name
self._subject = self._account_validity.renew_email_subject % { self._subject = account_validity_renew_email_subject % {"app": app_name}
"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: except Exception:
# If substitution failed, fall back to the bare strings. # If substitution failed, fall back to the bare strings.
self._subject = self._account_validity.renew_email_subject self._subject = account_validity_renew_email_subject
self._from_string = self.hs.config.email_notif_from self._from_string = hs.config.email_notif_from
self._raw_from = email.utils.parseaddr(self._from_string)[1] self._raw_from = email.utils.parseaddr(self._from_string)[1]
@ -220,50 +236,87 @@ class AccountValidityHandler:
attempts += 1 attempts += 1
raise StoreError(500, "Couldn't generate a unique string as refresh string.") 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 """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. 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: Args:
renewal_token: Token sent with the renewal request. renewal_token: Token sent with the renewal request.
Returns: 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: 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: 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) 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( async def renew_account_for_user(
self, self,
user_id: str, user_id: str,
expiration_ts: Optional[int] = None, expiration_ts: Optional[int] = None,
email_sent: bool = False, email_sent: bool = False,
renewal_token: Optional[str] = None,
) -> int: ) -> int:
"""Renews the account attached to a given user by pushing back the """Renews the account attached to a given user by pushing back the
expiration date by the current validity period in the server's expiration date by the current validity period in the server's
configuration. configuration.
Args: 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. expiration_ts: New expiration date. Defaults to now + validity period.
email_sen: Whether an email has been sent for this validity period. email_sent: Whether an email has been sent for this validity period.
Defaults to False. renewal_token: Token sent with the renewal request. The user's token
will be cleared if this is None.
Returns: Returns:
New expiration date for this account, as a timestamp in New expiration date for this account, as a timestamp in
milliseconds since epoch. milliseconds since epoch.
""" """
now = self.clock.time_msec()
if expiration_ts is None: 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( 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 return expiration_ts

View file

@ -49,7 +49,9 @@ class DeactivateAccountHandler(BaseHandler):
if hs.config.run_background_tasks: if hs.config.run_background_tasks:
hs.get_reactor().callWhenRunning(self._start_user_parting) 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( async def deactivate_account(
self, self,

View file

@ -62,7 +62,9 @@ class PusherPool:
self.store = self.hs.get_datastore() self.store = self.hs.get_datastore()
self.clock = self.hs.get_clock() 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. # We shard the handling of push notifications by user ID.
self._pusher_shard_config = hs.config.push.pusher_shard_config self._pusher_shard_config = hs.config.push.pusher_shard_config
@ -236,7 +238,7 @@ class PusherPool:
for u in users_affected: for u in users_affected:
# Don't push if the user account has expired # 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( expired = await self.store.is_account_expired(
u, self.clock.time_msec() u, self.clock.time_msec()
) )
@ -266,7 +268,7 @@ class PusherPool:
for u in users_affected: for u in users_affected:
# Don't push if the user account has expired # 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( expired = await self.store.is_account_expired(
u, self.clock.time_msec() u, self.clock.time_msec()
) )

View file

@ -0,0 +1 @@
<html><body>Your account is valid until {{ expiration_ts|format_ts("%d-%m-%Y") }}.</body><html>

View file

@ -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>

View file

@ -36,24 +36,40 @@ class AccountValidityRenewServlet(RestServlet):
self.hs = hs self.hs = hs
self.account_activity_handler = hs.get_account_validity_handler() self.account_activity_handler = hs.get_account_validity_handler()
self.auth = hs.get_auth() self.auth = hs.get_auth()
self.success_html = hs.config.account_validity.account_renewed_html_content self.account_renewed_template = (
self.failure_html = hs.config.account_validity.invalid_token_html_content 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): async def on_GET(self, request):
if b"token" not in request.args: if b"token" not in request.args:
raise SynapseError(400, "Missing renewal token") raise SynapseError(400, "Missing renewal token")
renewal_token = request.args[b"token"][0] 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") renewal_token.decode("utf8")
) )
if token_valid: if token_valid:
status_code = 200 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: else:
status_code = 404 status_code = 404
response = self.failure_html response = self.invalid_token_template.render(expiration_ts=expiration_ts)
respond_with_html(request, status_code, response) respond_with_html(request, status_code, response)
@ -71,10 +87,12 @@ class AccountValiditySendMailServlet(RestServlet):
self.hs = hs self.hs = hs
self.account_activity_handler = hs.get_account_validity_handler() self.account_activity_handler = hs.get_account_validity_handler()
self.auth = hs.get_auth() 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): 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( raise AuthError(
403, "Account renewal via email is disabled on this server." 403, "Account renewal via email is disabled on this server."
) )

View file

@ -91,8 +91,20 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
id_column=None, id_column=None,
) )
self._account_validity = hs.config.account_validity self._account_validity_enabled = (
if hs.config.run_background_tasks and 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( self._clock.call_later(
0.0, 0.0,
self._set_expiration_date_when_missing, self._set_expiration_date_when_missing,
@ -194,6 +206,7 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
expiration_ts: int, expiration_ts: int,
email_sent: bool, email_sent: bool,
renewal_token: Optional[str] = None, renewal_token: Optional[str] = None,
token_used_ts: Optional[int] = None,
) -> None: ) -> None:
"""Updates the account validity properties of the given account, with the """Updates the account validity properties of the given account, with the
given values. given values.
@ -207,6 +220,8 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
period. period.
renewal_token: Renewal token the user can use to extend the validity renewal_token: Renewal token the user can use to extend the validity
of their account. Defaults to no token. 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): def set_account_validity_for_user_txn(txn):
@ -218,6 +233,7 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
"expiration_ts_ms": expiration_ts, "expiration_ts_ms": expiration_ts,
"email_sent": email_sent, "email_sent": email_sent,
"renewal_token": renewal_token, "renewal_token": renewal_token,
"token_used_ts_ms": token_used_ts,
}, },
) )
self._invalidate_cache_and_stream( self._invalidate_cache_and_stream(
@ -231,7 +247,7 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
async def set_renewal_token_for_user( async def set_renewal_token_for_user(
self, user_id: str, renewal_token: str self, user_id: str, renewal_token: str
) -> None: ) -> None:
"""Defines a renewal token for a given user. """Defines a renewal token for a given user, and clears the token_used timestamp.
Args: Args:
user_id: ID of the user to set the renewal token for. 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( await self.db_pool.simple_update_one(
table="account_validity", table="account_validity",
keyvalues={"user_id": user_id}, 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", desc="set_renewal_token_for_user",
) )
async def get_user_from_renewal_token(self, renewal_token: str) -> str: async def get_user_from_renewal_token(
"""Get a user ID from a renewal token. self, renewal_token: str
) -> Tuple[str, int, Optional[int]]:
"""Get a user ID and renewal status from a renewal token.
Args: Args:
renewal_token: The renewal token to perform the lookup with. renewal_token: The renewal token to perform the lookup with.
Returns: 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", table="account_validity",
keyvalues={"renewal_token": renewal_token}, keyvalues={"renewal_token": renewal_token},
retcol="user_id", retcols=["user_id", "expiration_ts_ms", "token_used_ts_ms"],
desc="get_user_from_renewal_token", 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: async def get_renewal_token_for_user(self, user_id: str) -> str:
"""Get the renewal token associated with a given user ID. """Get the renewal token associated with a given user ID.
@ -302,7 +332,7 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
"get_users_expiring_soon", "get_users_expiring_soon",
select_users_txn, select_users_txn,
self._clock.time_msec(), 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: 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. delta equal to 10% of the validity period.
""" """
now_ms = self._clock.time_msec() 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: if use_delta:
expiration_ts = self.rand.randrange( 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, expiration_ts,
) )
@ -1412,7 +1442,7 @@ class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore):
except self.database_engine.module.IntegrityError: except self.database_engine.module.IntegrityError:
raise StoreError(400, "User ID already taken.", errcode=Codes.USER_IN_USE) 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) self.set_expiration_date_for_user_txn(txn, user_id)
if create_profile_with_displayname: if create_profile_with_displayname:

View file

@ -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;

View file

@ -492,8 +492,8 @@ class AccountValidityRenewalByEmailTestCase(unittest.HomeserverTestCase):
(user_id, tok) = self.create_user() (user_id, tok) = self.create_user()
# Move 6 days forward. This should trigger a renewal email to be sent. # Move 5 days forward. This should trigger a renewal email to be sent.
self.reactor.advance(datetime.timedelta(days=6).total_seconds()) self.reactor.advance(datetime.timedelta(days=5).total_seconds())
self.assertEqual(len(self.email_attempts), 1) self.assertEqual(len(self.email_attempts), 1)
# Retrieving the URL from the email is too much pain for now, so we # 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) self.assertEquals(channel.result["code"], b"200", channel.result)
# Check that we're getting HTML back. # Check that we're getting HTML back.
content_type = None content_type = channel.headers.getRawHeaders(b"Content-Type")
for header in channel.result.get("headers", []): self.assertEqual(content_type, [b"text/html; charset=utf-8"], channel.result)
if header[0] == b"Content-Type":
content_type = header[1]
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. # 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( self.assertEqual(
channel.result["body"], expected_html.encode("utf8"), channel.result 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) self.assertEquals(channel.result["code"], b"404", channel.result)
# Check that we're getting HTML back. # Check that we're getting HTML back.
content_type = None content_type = channel.headers.getRawHeaders(b"Content-Type")
for header in channel.result.get("headers", []): self.assertEqual(content_type, [b"text/html; charset=utf-8"], channel.result)
if header[0] == b"Content-Type":
content_type = header[1]
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 # Check that the HTML we're getting is the one we expect when using an
# invalid/unknown token. # 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( self.assertEqual(
channel.result["body"], expected_html.encode("utf8"), channel.result channel.result["body"], expected_html.encode("utf8"), channel.result
) )
@ -647,7 +664,12 @@ class AccountValidityBackgroundJobTestCase(unittest.HomeserverTestCase):
config["account_validity"] = {"enabled": False} config["account_validity"] = {"enabled": False}
self.hs = self.setup_test_homeserver(config=config) 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() self.store = self.hs.get_datastore()