Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0d75d6a
Support renewing an authenticated token
babolivier May 4, 2021
4b04f89
Generate and send shorter authenticated tokens if configured to do so
babolivier May 5, 2021
28a8d4a
Document send_link and templates in the README.md
babolivier May 5, 2021
c1c0b48
Add test for authenticated tokens
babolivier May 5, 2021
83852bf
Actually error when trying to reuse a unique renewal token
babolivier May 5, 2021
ff9433b
Merge branch 'master' into babolivier/token
babolivier May 5, 2021
a1b5743
call_args.kwargs was only introduced in Python 3.8
babolivier May 5, 2021
04ed67b
Use the right assertion method
babolivier May 5, 2021
083668c
Import from Synapse's module API
babolivier May 14, 2021
caa23ff
Look for templates in the module; globalise config
babolivier May 20, 2021
56e4841
Fix test infra
babolivier May 20, 2021
c02b930
Incorporate review
babolivier May 20, 2021
0efb472
Fix test
babolivier May 20, 2021
5663b3f
Fix is_user_expired to match expected API
babolivier May 24, 2021
d42ee31
Fix tests
babolivier May 24, 2021
6978508
Move to the new module system
babolivier Jul 1, 2021
605337c
Use indexes to check unicity on tokens
babolivier Jul 2, 2021
41eaf8f
Fix tests
babolivier Jul 2, 2021
5377864
Update docs
babolivier Jul 2, 2021
ccc9a81
Migrate to new module interface
babolivier Jul 19, 2021
29b5634
Merge branch 'main' into babolivier/new_api
babolivier Jul 19, 2021
de0470e
Merge branch 'main' into babolivier/token
babolivier Jul 19, 2021
f086863
Fix tests
babolivier Jul 19, 2021
7b8624c
Merge branch 'babolivier/new_api' into babolivier/token
babolivier Jul 19, 2021
ddc2640
Update README too
babolivier Jul 19, 2021
a90ba0a
Update minimal required synapse version (for real this time)
babolivier Jul 19, 2021
74bd1f5
Update minimal required synapse version (for real this time)
babolivier Jul 19, 2021
c28e129
Merge branch 'babolivier/new_api' into babolivier/token
babolivier Jul 19, 2021
d8da9eb
Merge branch 'main' into babolivier/token
babolivier Aug 11, 2021
ccb68a6
Incorporate review
babolivier Aug 16, 2021
f9e2c0f
Update README.md
babolivier Aug 16, 2021
ee8ee76
Validate short tokens
babolivier Aug 16, 2021
efd23fd
Merge branch 'babolivier/token' of github.com:matrix-org/synapse-emai…
babolivier Aug 16, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ Add the following in your Synapse config under `account_validity`:
period: 6w
# How long before an account expires should Synapse send it a renewal email.
renew_at: 1w
# Whether to include a link to click in the emails sent to users. If false, only a
# renewal token is sent, in which case it is generated so it's simpler, and the
# user will need to copy it into a compatible client that will send an
# authenticated request to the server.
# Defaults to true.
send_links: true
```

Also under the HTTP client `listener`, configure an `additional_resource` as per below:
Expand All @@ -38,12 +44,50 @@ Also under the HTTP client `listener`, configure an `additional_resource` as per
# The maximum amount of time an account can stay valid for without being
# renewed.
period: 6w
# Whether to include a link to click in the emails sent to users. If false,
# only a renewal token is sent, in which case it is generated so it's simpler,
# and the user will need to copy it into a compatible client that will send an
# authenticated request to the server.
# Defaults to true.
send_links: true
```

The syntax for durations is the same as in the rest of Synapse's configuration file.

Configuration parameters with matching names that appear both in `account_validity` and
`listeners` __must__ have the same value in both places, otherwise the module will not
behave correctly.

## Templates

If they are not already there, copy the [templates](/email_account_validity/templates)
into Synapse's templates directory.
into Synapse's templates directory (or replace them with your own). The templates the
module will use are:

* `notice_expiry.(html|txt)`: The content of the renewal email. It gets passed the
following variables:
* `display_name`: The display name of the user needing renewal.
* `expiration_ts`: A timestamp in milliseconds representing when the account will
expire. Templates can use the `format_ts` (with a date format as the function's
parameter) to format this timestamp into a human-readable date.
* `url`: The URL the user is supposed to click on to renew their account. If
`send_links` is set to `false` in the module's configuration, the value of this
variable will be the token the user must copy into their client.
* `renewal_token`: The token to use in order to renew the user's account. If
`send_links` is set to `false`, templates should prefer this variable to `url`.
* `account_renewed.html`: The HTML to display to a user when they successfully renew
their account. It gets passed the following vaiables:
* `expiration_ts`: A timestamp in milliseconds representing when the account will
expire. Templates can use the `format_ts` (with a date format as the function's
parameter) to format this timestamp into a human-readable date.
* `account_previously_renewed.html`: The HTML to display to a user when they try to renew
their account with a token that's valid but previously used. It gets passed the same
variables as `account_renewed.html`.
* `invalid_token.html`: The HTML to display to a user when they try to renew their account
with the wrong token. It doesn't get passed any variable.

Note that the templates directory contains two files that aren't templates (`mail.css`
and `mail-expiry.css`), but are used by email templates to apply visual adjustments.

## Routes

Expand Down
70 changes: 51 additions & 19 deletions email_account_validity/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@

from twisted.web.server import Request

from synapse.api.errors import StoreError, SynapseError
from synapse.http.servlet import parse_json_object_from_request
from synapse.module_api import ModuleApi
from synapse.module_api.errors import SynapseError
from synapse.types import UserID
from synapse.util import stringutils

from email_account_validity._store import EmailAccountValidityStore
from email_account_validity._utils import random_digit_string

logger = logging.getLogger(__name__)

Expand All @@ -35,6 +36,7 @@ def __init__(self, config: Any, api: ModuleApi, store: EmailAccountValidityStore
self._store = store

self._period = config.get("period")
self._send_links = config.get("send_links", True)

(self._template_html, self._template_text,) = api.read_templates(
["notice_expiry.html", "notice_expiry.txt"],
Expand Down Expand Up @@ -102,38 +104,45 @@ async def send_renewal_email(self, user_id: str, expiration_ts: int):
display_name = profile.display_name
if display_name is None:
display_name = user_id
except StoreError:
except SynapseError:
display_name = user_id

renewal_token = await self.generate_renewal_token(user_id)

url = "%s_synapse/client/email_account_validity/renew?token=%s" % (
self._api.public_baseurl,
renewal_token,
)
if self._send_links:
url = "%s_synapse/client/email_account_validity/renew?token=%s" % (
self._api.public_baseurl,
renewal_token,
)
else:
# If we're not supposed to send a URL, fallback to the URL being just the
# token. Templates should be using the `renewal_token` variable, but we do
# this so old templates don't break.
url = renewal_token

template_vars = {
"display_name": display_name,
"expiration_ts": expiration_ts,
"url": url,
"renewal_token": renewal_token,
}

html_text = self._template_html.render(**template_vars)
plain_text = self._template_text.render(**template_vars)

for address in addresses:
await self._api.send_mail(
address,
self._renew_email_subject,
html_text,
plain_text,
recipient=address,
subject=self._renew_email_subject,
html=html_text,
text=plain_text,
)

await self._store.set_renewal_mail_status(user_id=user_id, email_sent=True)

async def generate_renewal_token(self, user_id: str) -> str:
"""Generates a 32-byte long random string that will be inserted into the
user's renewal email's unique link, then saves it into the database.
"""Generates a random string that will be inserted into the user's renewal email,
then saves it into the database.

Args:
user_id: ID of the user to generate a string for.
Expand All @@ -142,19 +151,38 @@ async def generate_renewal_token(self, user_id: str) -> str:
The generated string.

Raises:
StoreError(500): Couldn't generate a unique string after 5 attempts.
SynapseError(500): Couldn't generate a unique string after 5 attempts.
"""
if not self._send_links:
# If the user isn't expected to click on a link, they're expected to enter a
# token manually into their client, which in turn sends the renewal request
# to the server, authenticated with an access token. This means that in this
# case we need the token to be shorter and less complex (hence the 8 digits
# string), but also that we don't need to make the token unique across the
# whole database.
renewal_token = random_digit_string(8)
await self._store.set_renewal_token_for_user(
user_id, renewal_token, unique=False,
)
return renewal_token

attempts = 0
while attempts < 5:
try:
renewal_token = stringutils.random_string(32)
await self._store.set_renewal_token_for_user(user_id, renewal_token)
await self._store.set_renewal_token_for_user(
user_id, renewal_token, unique=True,
)
return renewal_token
except StoreError:
except SynapseError:
attempts += 1
raise StoreError(500, "Couldn't generate a unique string as refresh string.")
raise SynapseError(500, "Couldn't generate a unique string as refresh string.")

async def renew_account(self, renewal_token: str) -> Tuple[bool, bool, int]:
async def renew_account(
self,
renewal_token: str,
user_id: Optional[str] = None,
) -> 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.

Expand All @@ -164,6 +192,9 @@ async def renew_account(self, renewal_token: str) -> Tuple[bool, bool, int]:

Args:
renewal_token: Token sent with the renewal request.
user_id: The Matrix ID of the user to renew, if the renewal request was
authenticated.

Returns:
A tuple containing:
* A bool representing whether the token is valid and unused.
Expand All @@ -176,13 +207,14 @@ async def renew_account(self, renewal_token: str) -> Tuple[bool, bool, int]:
# was called from a place it shouldn't have been, e.g. the /send_mail servlet.
raise SynapseError(500, "Tried to renew account in unexpected place")

# Verify if the token, or the (token, user_id) tuple, exists.
try:
(
user_id,
current_expiration_ts,
token_used_ts,
) = await self._store.get_user_from_renewal_token(renewal_token)
except StoreError:
) = await self._store.get_user_from_renewal_token(renewal_token, user_id)
except SynapseError:
return False, False, 0

# Check whether this token has already been used.
Expand Down
41 changes: 38 additions & 3 deletions email_account_validity/_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from typing import Dict, List, Optional, Tuple, Union

from synapse.module_api import ModuleApi
from synapse.module_api.errors import SynapseError
from synapse.storage.database import DatabasePool, LoggingTransaction
from synapse.util.caches.descriptors import cached

Expand Down Expand Up @@ -246,8 +247,36 @@ def get_expiration_ts_for_user_txn(txn: LoggingTransaction):
)
return res

async def set_renewal_token_for_user(self, user_id: str, renewal_token: str):
async def set_renewal_token_for_user(
self,
user_id: str,
renewal_token: str,
unique: bool,
):
"""Store the given renewal token for the given user.

Args:
user_id: The user ID to store the renewal token for.
renewal_token: The renewal token to store for the user.
unique: Whether the token should be unique across the whole database, i.e.
whether it should be able to look the user up from the token.

Raises:
SynapseError(409): unique is set to True and the token is already in use.
"""
def set_renewal_token_for_user_txn(txn: LoggingTransaction):
if unique:
ret = DatabasePool.simple_select_one_onecol_txn(
txn=txn,
table="email_account_validity",
keyvalues={"renewal_token": renewal_token},
retcol="user_id",
allow_none=True,
)

if ret is not None:
raise SynapseError(409, "Renewal token already in use")

DatabasePool.simple_update_one_txn(
txn=txn,
table="email_account_validity",
Expand All @@ -261,12 +290,14 @@ def set_renewal_token_for_user_txn(txn: LoggingTransaction):
)

async def get_user_from_renewal_token(
self, renewal_token: str
self, renewal_token: str, user_id: Optional[str] = None,
) -> 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.
user_id: The Matrix ID of the user to renew, if the renewal request was
authenticated.

Returns:
A tuple of containing the following values:
Expand All @@ -279,10 +310,14 @@ async def get_user_from_renewal_token(
"""

def get_user_from_renewal_token_txn(txn: LoggingTransaction):
keyvalues = {"renewal_token": renewal_token}
if user_id is not None:
keyvalues["user_id"] = user_id

return DatabasePool.simple_select_one_txn(
txn=txn,
table="email_account_validity",
keyvalues={"renewal_token": renewal_token},
keyvalues=keyvalues,
retcols=["user_id", "expiration_ts_ms", "token_used_ts_ms"],
)

Expand Down
28 changes: 28 additions & 0 deletions email_account_validity/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Copyright 2021 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.

import random
import re
import string


UNAUTHENTICATED_TOKEN_REGEX = re.compile('^[a-zA-Z]{32}$')

# We use SystemRandom to make sure we get cryptographically-secure randoms.
rand = random.SystemRandom()


def random_digit_string(length):
return "".join(rand.choice(string.digits) for _ in range(length))
16 changes: 15 additions & 1 deletion email_account_validity/servlets.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from twisted.web.resource import Resource

from synapse.api.errors import InvalidClientCredentialsError
from synapse.config._base import Config, ConfigError
from synapse.http.server import (
DirectServeHtmlResource,
Expand All @@ -26,6 +27,7 @@

from email_account_validity._base import EmailAccountValidityBase
from email_account_validity._store import EmailAccountValidityStore
from email_account_validity._utils import UNAUTHENTICATED_TOKEN_REGEX


class EmailAccountValidityServlet(Resource):
Expand Down Expand Up @@ -77,11 +79,23 @@ async def _async_render_GET(self, request):

renewal_token = request.args[b"token"][0].decode("utf-8")

user_id = None
if not UNAUTHENTICATED_TOKEN_REGEX.match(renewal_token):
# If the token doesn't look like one we might send as a clickable link via
# email, try to authenticate the request.
try:
requester = await self._api.get_user_by_req(request, allow_expired=True)
except InvalidClientCredentialsError:
respond_with_html(request, 404, self._invalid_token_template.render())
return

user_id = requester.user.to_string()

(
token_valid,
token_stale,
expiration_ts,
) = await self.renew_account(renewal_token)
) = await self.renew_account(renewal_token, user_id)

if token_valid:
status_code = 200
Expand Down
9 changes: 4 additions & 5 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,13 @@ async def send_mail(recipient, subject, html, text):
return None


async def create_account_validity_module() -> EmailAccountValidity:
async def create_account_validity_module(config_override={}) -> EmailAccountValidity:
"""Starts an EmailAccountValidity module with a basic config and a mock of the
ModuleApi.
"""
config = {
"period": 3628800000, # 6w
"renew_at": 604800000, # 1w
}
config = config_override
config.setdefault("period", 3628800000) # 6w
config.setdefault("renew_at", 604800000) # 1w

store = SQLiteStore()

Expand Down
Loading