├── .github
├── CODEOWNERS
└── workflows
│ └── tests.yml
├── .gitignore
├── LICENSE
├── README.md
├── email_account_validity
├── __init__.py
├── _base.py
├── _config.py
├── _servlets.py
├── _store.py
├── _utils.py
├── account_validity.py
└── templates
│ ├── account_previously_renewed.html
│ ├── account_renewed.html
│ ├── invalid_token.html
│ ├── mail-expiry.css
│ ├── mail.css
│ ├── notice_expiry.html
│ └── notice_expiry.txt
├── setup.py
├── tests
├── __init__.py
└── test_account_validity.py
└── tox.ini
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | # Automatically request reviews from the synapse-core team when a pull request comes in.
2 | * @matrix-org/synapse-core
3 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on:
4 | push:
5 | branches: ["main"]
6 | pull_request:
7 |
8 | jobs:
9 | tests:
10 | runs-on: ubuntu-latest
11 | strategy:
12 | matrix:
13 | python-version: ["3.7", "3.x"]
14 |
15 | steps:
16 | - uses: actions/checkout@v2
17 | - uses: actions/setup-python@v2
18 | with:
19 | python-version: ${{ matrix.python-version }}
20 | - run: pip install tox
21 | - run: tox -e tests
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | .coverage
3 | __pycache__/
4 | *.pyc
5 | env/
6 | _trial_temp/
7 | *.db
8 |
9 | /.eggs/
10 | /.idea/
11 | /.tox/
12 | /build/
13 | /dist/
14 | /*.egg-info/
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Synapse email account validity
2 |
3 | A Synapse plugin module to manage account validity using validation emails.
4 |
5 | After a configured time, this module automatically expires user accounts. When a user is
6 | expired, Synapse will respond to any request authenticated by the user's access token
7 | with a 403 error containing the `ORG_MATRIX_EXPIRED_ACCOUNT` error code. Some time before
8 | an account expires (defined by the module's configuration), an email is sent to the user
9 | with instructions to renew the validity of their account.
10 |
11 | This module requires:
12 |
13 | * Synapse >= 1.39.0
14 | * sqlite3 >= 3.24.0 (if using SQLite with Synapse (not recommended))
15 |
16 | ## Installation
17 |
18 | Thi plugin can be installed via PyPI:
19 |
20 | ```
21 | pip install synapse-email-account-validity
22 | ```
23 |
24 | ## Config
25 |
26 | Add the following in your Synapse config:
27 |
28 | ```yaml
29 | modules:
30 | - module: email_account_validity.EmailAccountValidity
31 | config:
32 | # The maximum amount of time an account can stay valid for without being renewed.
33 | period: 6w
34 | # How long before an account expires should Synapse send it a renewal email.
35 | renew_at: 1w
36 | # Whether to include a link to click in the emails sent to users. If false, only a
37 | # renewal token is sent, in which case a shorter token is used, and the
38 | # user will need to copy it into a compatible client that will send an
39 | # authenticated request to the server.
40 | # Defaults to true.
41 | send_links: true
42 | ```
43 |
44 | The syntax for durations is the same as in the rest of Synapse's configuration file.
45 |
46 | ## Templates
47 |
48 | The templates the module will use are:
49 |
50 | * `notice_expiry.(html|txt)`: The content of the renewal email. It gets passed the
51 | following variables:
52 | * `app_name`: The value configured for `app_name` in the Synapse configuration file
53 | (under the `email` section).
54 | * `display_name`: The display name of the user needing renewal.
55 | * `expiration_ts`: A timestamp in milliseconds representing when the account will
56 | expire. Templates can use the `format_ts` (with a date format as the function's
57 | parameter) to format this timestamp into a human-readable date.
58 | * `url`: The URL the user is supposed to click on to renew their account. If
59 | `send_links` is set to `false` in the module's configuration, the value of this
60 | variable will be `None`.
61 | * `renewal_token`: The token to use in order to renew the user's account. If
62 | `send_links` is set to `false`, templates should prefer this variable to `url`.
63 | * `account_renewed.html`: The HTML to display to a user when they successfully renew
64 | their account. It gets passed the following vaiables:
65 | * `expiration_ts`: A timestamp in milliseconds representing when the account will
66 | expire. Templates can use the `format_ts` (with a date format as the function's
67 | parameter) to format this timestamp into a human-readable date.
68 | * `account_previously_renewed.html`: The HTML to display to a user when they try to renew
69 | their account with a token that's valid but previously used. It gets passed the same
70 | variables as `account_renewed.html`.
71 | * `invalid_token.html`: The HTML to display to a user when they try to renew their account
72 | with the wrong token. It doesn't get passed any variable.
73 |
74 | You can find and change the default templates [here](https://github.com/matrix-org/synapse-email-account-validity/tree/main/email_account_validity/templates).
75 | Admins can install custom templates either by changing the default ones directly, or by
76 | configuring Synapse with a custom template directory that contains the custom templates.
77 | Note that the templates directory contains two files that aren't templates (`mail.css`
78 | and `mail-expiry.css`), but are used by email templates to apply visual adjustments.
79 |
80 | Admins that don't need to customise their templates can just use the module as is and
81 | ignore the previous paragraph.
82 |
83 | ## Routes
84 |
85 | This plugin exposes three HTTP routes to manage account validity:
86 |
87 | * `POST /_synapse/client/email_account_validity/send_mail`, which any registered user can
88 | hit with an access token to request a renewal email to be sent to their email addresses.
89 | * `GET /_synapse/client/email_account_validity/renew`, which requires a `token` query
90 | parameter containing the latest token sent via email to the user, which renews the
91 | account associated with the token.
92 | * `POST /_synapse/client/email_account_validity/admin`, which any server admin can use to
93 | manage the account validity of any registered user. It takes a JSON body with the
94 | following keys:
95 | * `user_id` (string, required): The Matrix ID of the user to update.
96 | * `expiration_ts` (integer, optional): The new expiration timestamp for this user, in
97 | milliseconds. If no token is provided, a value corresponding to `now + period` is
98 | used.
99 | * `enable_renewal_emails` (boolean, optional): Whether to allow renewal emails to be
100 | sent to this user.
101 |
102 | The two first routes need to be reachable by the end users for this feature to work as
103 | intended.
104 |
105 | ## Development and Testing
106 |
107 | This repository uses `tox` to run tests.
108 |
109 | ### Tests
110 |
111 | This repository uses `unittest` to run the tests located in the `tests`
112 | directory. They can be ran with `tox -e tests`.
113 |
114 | ### Making a release
115 |
116 | ```
117 | git tag vX.Y
118 | python3 setup.py sdist
119 | twine upload dist/synapse-email-account-validity-X.Y.tar.gz
120 | git push origin vX.Y
121 | ```
122 |
--------------------------------------------------------------------------------
/email_account_validity/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright 2021 The Matrix.org Foundation C.I.C.
3 | #
4 | # Licensed under the Apache License, Version 2.0 (the "License");
5 | # you may not use this file except in compliance with the License.
6 | # You may obtain a copy of the License at
7 | #
8 | # http://www.apache.org/licenses/LICENSE-2.0
9 | #
10 | # Unless required by applicable law or agreed to in writing, software
11 | # distributed under the License is distributed on an "AS IS" BASIS,
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | # See the License for the specific language governing permissions and
14 | # limitations under the License.
15 |
16 | from pkg_resources import DistributionNotFound, get_distribution
17 |
18 | from email_account_validity.account_validity import EmailAccountValidity
19 |
20 | try:
21 | __version__ = get_distribution(__name__).version
22 | except DistributionNotFound:
23 | # package is not installed
24 | pass
25 |
26 | __all__ = ["EmailAccountValidity"]
--------------------------------------------------------------------------------
/email_account_validity/_base.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright 2021 The Matrix.org Foundation C.I.C.
3 | #
4 | # Licensed under the Apache License, Version 2.0 (the "License");
5 | # you may not use this file except in compliance with the License.
6 | # You may obtain a copy of the License at
7 | #
8 | # http://www.apache.org/licenses/LICENSE-2.0
9 | #
10 | # Unless required by applicable law or agreed to in writing, software
11 | # distributed under the License is distributed on an "AS IS" BASIS,
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | # See the License for the specific language governing permissions and
14 | # limitations under the License.
15 |
16 | import logging
17 | import os
18 | import time
19 | from typing import Optional, Tuple
20 |
21 | from twisted.web.server import Request
22 |
23 | from synapse.module_api import ModuleApi, UserID, parse_json_object_from_request
24 | from synapse.module_api.errors import SynapseError
25 |
26 | from email_account_validity._config import EmailAccountValidityConfig
27 | from email_account_validity._store import EmailAccountValidityStore
28 | from email_account_validity._utils import (
29 | LONG_TOKEN_REGEX,
30 | SHORT_TOKEN_REGEX,
31 | random_digit_string,
32 | random_string,
33 | TokenFormat,
34 | )
35 |
36 | logger = logging.getLogger(__name__)
37 |
38 |
39 | class EmailAccountValidityBase:
40 | def __init__(
41 | self,
42 | config: EmailAccountValidityConfig,
43 | api: ModuleApi,
44 | store: EmailAccountValidityStore,
45 | ):
46 | self._api = api
47 | self._store = store
48 |
49 | self._period = config.period
50 | self._send_links = config.send_links
51 |
52 | (self._template_html, self._template_text,) = api.read_templates(
53 | ["notice_expiry.html", "notice_expiry.txt"],
54 | os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates"),
55 | )
56 |
57 | if config.renew_email_subject is not None:
58 | renew_email_subject = config.renew_email_subject
59 | else:
60 | renew_email_subject = "Renew your %(app)s account"
61 |
62 | try:
63 | app_name = self._api.email_app_name
64 | self._renew_email_subject = renew_email_subject % {"app": app_name}
65 | except (KeyError, TypeError):
66 | # If substitution failed, fall back to the bare strings.
67 | self._renew_email_subject = renew_email_subject
68 |
69 | async def send_renewal_email_to_user(self, user_id: str) -> None:
70 | """
71 | Send a renewal email for a specific user.
72 |
73 | Args:
74 | user_id: The user ID to send a renewal email for.
75 |
76 | Raises:
77 | SynapseError if the user is not set to renew.
78 | """
79 | expiration_ts = await self._store.get_expiration_ts_for_user(user_id)
80 |
81 | # If this user isn't set to be expired, raise an error.
82 | if expiration_ts is None:
83 | raise SynapseError(400, "User has no expiration time: %s" % (user_id,))
84 |
85 | await self.send_renewal_email(user_id, expiration_ts)
86 |
87 | async def send_renewal_email(self, user_id: str, expiration_ts: int):
88 | """Sends out a renewal email to every email address attached to the given user
89 | with a unique link allowing them to renew their account.
90 |
91 | Args:
92 | user_id: ID of the user to send email(s) to.
93 | expiration_ts: Timestamp in milliseconds for the expiration date of
94 | this user's account (used in the email templates).
95 | """
96 | threepids = await self._api.get_threepids_for_user(user_id)
97 |
98 | addresses = []
99 | for threepid in threepids:
100 | if threepid["medium"] == "email":
101 | addresses.append(threepid["address"])
102 |
103 | # Stop right here if the user doesn't have at least one email address.
104 | # In this case, they will have to ask their server admin to renew their account
105 | # manually.
106 | # We don't need to do a specific check to make sure the account isn't
107 | # deactivated, as a deactivated account isn't supposed to have any email address
108 | # attached to it.
109 | if not addresses:
110 | return
111 |
112 | try:
113 | profile = await self._api.get_profile_for_user(
114 | UserID.from_string(user_id).localpart
115 | )
116 | display_name = profile.display_name
117 | if display_name is None:
118 | display_name = user_id
119 | except SynapseError:
120 | display_name = user_id
121 |
122 | # If the user isn't expected to click on a link, but instead to copy the token
123 | # into their client, we generate a different kind of token, simpler and shorter,
124 | # because a) we don't need it to be unique to the whole table and b) we want the
125 | # user to be able to be easily type it back into their client.
126 | if self._send_links:
127 | renewal_token = await self.generate_unauthenticated_renewal_token(user_id)
128 |
129 | url = "%s_synapse/client/email_account_validity/renew?token=%s" % (
130 | self._api.public_baseurl,
131 | renewal_token,
132 | )
133 | else:
134 | renewal_token = await self.generate_authenticated_renewal_token(user_id)
135 | url = None
136 |
137 | template_vars = {
138 | "app_name": self._api.email_app_name,
139 | "display_name": display_name,
140 | "expiration_ts": expiration_ts,
141 | "url": url,
142 | "renewal_token": renewal_token,
143 | }
144 |
145 | html_text = self._template_html.render(**template_vars)
146 | plain_text = self._template_text.render(**template_vars)
147 |
148 | for address in addresses:
149 | await self._api.send_mail(
150 | recipient=address,
151 | subject=self._renew_email_subject,
152 | html=html_text,
153 | text=plain_text,
154 | )
155 |
156 | await self._store.set_renewal_mail_status(user_id=user_id, email_sent=True)
157 |
158 | async def generate_authenticated_renewal_token(self, user_id: str) -> str:
159 | """Generates a 8-digit long random string then saves it into the database.
160 |
161 | This token is to be sent to the user over email so that the user can copy it into
162 | their client to renew their account.
163 |
164 | Args:
165 | user_id: ID of the user to generate a string for.
166 |
167 | Returns:
168 | The generated string.
169 |
170 | Raises:
171 | SynapseError(500): Couldn't generate a unique string after 5 attempts.
172 | """
173 | renewal_token = random_digit_string(8)
174 | await self._store.set_renewal_token_for_user(
175 | user_id, renewal_token, TokenFormat.SHORT,
176 | )
177 | return renewal_token
178 |
179 | async def generate_unauthenticated_renewal_token(self, user_id: str) -> str:
180 | """Generates a 32-letter long random string then saves it into the database.
181 |
182 | This token is to be sent to the user over email in a link that the user will then
183 | click to renew their account.
184 |
185 | Args:
186 | user_id: ID of the user to generate a string for.
187 |
188 | Returns:
189 | The generated string.
190 |
191 | Raises:
192 | SynapseError(500): Couldn't generate a unique string after 5 attempts.
193 | """
194 | attempts = 0
195 | while attempts < 5:
196 | try:
197 | renewal_token = random_string(32)
198 | await self._store.set_renewal_token_for_user(
199 | user_id, renewal_token, TokenFormat.LONG,
200 | )
201 | return renewal_token
202 | except SynapseError:
203 | attempts += 1
204 | raise SynapseError(500, "Couldn't generate a unique string as refresh string.")
205 |
206 | async def renew_account(
207 | self,
208 | renewal_token: str,
209 | user_id: Optional[str] = None,
210 | ) -> Tuple[bool, bool, int]:
211 | """Renews the account attached to a given renewal token by pushing back the
212 | expiration date by the current validity period in the server's configuration.
213 |
214 | If it turns out that the token is valid but has already been used, then the
215 | token is considered stale. A token is stale if the 'token_used_ts_ms' db column
216 | is non-null.
217 |
218 | Args:
219 | renewal_token: Token sent with the renewal request.
220 | user_id: The Matrix ID of the user to renew, if the renewal request was
221 | authenticated.
222 |
223 | Returns:
224 | A tuple containing:
225 | * A bool representing whether the token is valid and unused.
226 | * A bool which is `True` if the token is valid, but stale.
227 | * An int representing the user's expiry timestamp as milliseconds since the
228 | epoch, or 0 if the token was invalid.
229 | """
230 | # Try to match the token against a known format.
231 | if LONG_TOKEN_REGEX.match(renewal_token):
232 | token_format = TokenFormat.LONG
233 | elif SHORT_TOKEN_REGEX.match(renewal_token):
234 | token_format = TokenFormat.SHORT
235 | else:
236 | # If we can't figure out what format the renewal token is, consider it
237 | # invalid.
238 | return False, False, 0
239 |
240 | # If we were not able to authenticate the user requesting a renewal, and the
241 | # token needs authentication, consider the token neither valid nor stale.
242 | if user_id is None and token_format == TokenFormat.SHORT:
243 | return False, False, 0
244 |
245 | # Verify if the token, or the (token, user_id) tuple, exists.
246 | try:
247 | (
248 | user_id,
249 | current_expiration_ts,
250 | token_used_ts,
251 | ) = await self._store.validate_renewal_token(
252 | renewal_token,
253 | token_format,
254 | user_id,
255 | )
256 | except SynapseError:
257 | return False, False, 0
258 |
259 | # Check whether this token has already been used.
260 | if token_used_ts:
261 | logger.info(
262 | "User '%s' attempted to use previously used token '%s' to renew account",
263 | user_id,
264 | renewal_token,
265 | )
266 | return False, True, current_expiration_ts
267 |
268 | logger.debug("Renewing an account for user %s", user_id)
269 |
270 | # Renew the account. Pass the renewal_token here so that it is not cleared.
271 | # We want to keep the token around in case the user attempts to renew their
272 | # account with the same token twice (clicking the email link twice).
273 | #
274 | # In that case, the token will be accepted, but the account's expiration ts
275 | # will remain unchanged.
276 | new_expiration_ts = await self.renew_account_for_user(
277 | user_id, renewal_token=renewal_token
278 | )
279 |
280 | return True, False, new_expiration_ts
281 |
282 | async def renew_account_for_user(
283 | self,
284 | user_id: str,
285 | expiration_ts: Optional[int] = None,
286 | email_sent: bool = False,
287 | renewal_token: Optional[str] = None,
288 | ) -> int:
289 | """Renews the account attached to a given user by pushing back the
290 | expiration date by the current validity period in the server's
291 | configuration.
292 |
293 | Args:
294 | user_id: The ID of the user to renew.
295 | expiration_ts: New expiration date. Defaults to now + validity period.
296 | email_sent: Whether an email has been sent for this validity period.
297 | renewal_token: Token sent with the renewal request. The user's token
298 | will be cleared if this is None.
299 |
300 | Returns:
301 | New expiration date for this account, as a timestamp in
302 | milliseconds since epoch.
303 | """
304 | now = int(time.time() * 1000)
305 | if expiration_ts is None:
306 | expiration_ts = now + self._period
307 |
308 | await self._store.set_account_validity_for_user(
309 | user_id=user_id,
310 | expiration_ts=expiration_ts,
311 | email_sent=email_sent,
312 | token_format=TokenFormat.LONG if self._send_links else TokenFormat.SHORT,
313 | renewal_token=renewal_token,
314 | token_used_ts=now,
315 | )
316 |
317 | return expiration_ts
318 |
319 | async def set_account_validity_from_request(self, request: Request) -> int:
320 | """Set the account validity state of a user from a request's body. The body is
321 | expected to match the format for admin requests.
322 |
323 | Args:
324 | request: The request to extract data from.
325 |
326 | Returns:
327 | The new expiration timestamp for the updated user.
328 | """
329 | body = parse_json_object_from_request(request)
330 |
331 | if "user_id" not in body:
332 | raise SynapseError(400, "Missing property 'user_id' in the request body")
333 |
334 | return await self.renew_account_for_user(
335 | body["user_id"],
336 | body.get("expiration_ts"),
337 | not body.get("enable_renewal_emails", True),
338 | )
339 |
--------------------------------------------------------------------------------
/email_account_validity/_config.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright 2021 The Matrix.org Foundation C.I.C.
3 | #
4 | # Licensed under the Apache License, Version 2.0 (the "License");
5 | # you may not use this file except in compliance with the License.
6 | # You may obtain a copy of the License at
7 | #
8 | # http://www.apache.org/licenses/LICENSE-2.0
9 | #
10 | # Unless required by applicable law or agreed to in writing, software
11 | # distributed under the License is distributed on an "AS IS" BASIS,
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | # See the License for the specific language governing permissions and
14 | # limitations under the License.
15 | from typing import Optional
16 |
17 | import attr
18 |
19 |
20 | @attr.s(frozen=True, auto_attribs=True)
21 | class EmailAccountValidityConfig:
22 | period: int
23 | renew_at: int
24 | renew_email_subject: Optional[str] = None
25 | send_links: bool = True
26 |
--------------------------------------------------------------------------------
/email_account_validity/_servlets.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright 2021 The Matrix.org Foundation C.I.C.
3 | #
4 | # Licensed under the Apache License, Version 2.0 (the "License");
5 | # you may not use this file except in compliance with the License.
6 | # You may obtain a copy of the License at
7 | #
8 | # http://www.apache.org/licenses/LICENSE-2.0
9 | #
10 | # Unless required by applicable law or agreed to in writing, software
11 | # distributed under the License is distributed on an "AS IS" BASIS,
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | # See the License for the specific language governing permissions and
14 | # limitations under the License.
15 | import os
16 |
17 | from synapse.module_api import (
18 | DirectServeHtmlResource,
19 | DirectServeJsonResource,
20 | ModuleApi,
21 | respond_with_html,
22 | )
23 | from synapse.module_api.errors import (
24 | ConfigError,
25 | InvalidClientCredentialsError,
26 | SynapseError,
27 | )
28 | from twisted.web.resource import Resource
29 |
30 | from email_account_validity._base import EmailAccountValidityBase
31 | from email_account_validity._config import EmailAccountValidityConfig
32 | from email_account_validity._store import EmailAccountValidityStore
33 |
34 |
35 | class EmailAccountValidityServlet(Resource):
36 | def __init__(
37 | self,
38 | config: EmailAccountValidityConfig,
39 | api: ModuleApi,
40 | store: EmailAccountValidityStore,
41 | ):
42 | super().__init__()
43 | self.putChild(b'renew', EmailAccountValidityRenewServlet(config, api, store))
44 | self.putChild(
45 | b'send_mail',
46 | EmailAccountValiditySendMailServlet(config, api, store),
47 | )
48 | self.putChild(b'admin', EmailAccountValidityAdminServlet(config, api, store))
49 |
50 |
51 | class EmailAccountValidityRenewServlet(
52 | EmailAccountValidityBase, DirectServeHtmlResource
53 | ):
54 | def __init__(
55 | self,
56 | config: EmailAccountValidityConfig,
57 | api: ModuleApi,
58 | store: EmailAccountValidityStore,
59 | ):
60 | EmailAccountValidityBase.__init__(self, config, api, store)
61 | DirectServeHtmlResource.__init__(self)
62 |
63 | (
64 | self._account_renewed_template,
65 | self._account_previously_renewed_template,
66 | self._invalid_token_template,
67 | ) = api.read_templates(
68 | [
69 | "account_renewed.html",
70 | "account_previously_renewed.html",
71 | "invalid_token.html",
72 | ],
73 | os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates"),
74 | )
75 |
76 | async def _async_render_GET(self, request):
77 | """On GET requests on /renew, retrieve the given renewal token from the request
78 | query parameters and, if it matches with an account, renew the account.
79 | """
80 | if b"token" not in request.args:
81 | raise SynapseError(400, "Missing renewal token")
82 |
83 | renewal_token = request.args[b"token"][0].decode("utf-8")
84 |
85 | try:
86 | requester = await self._api.get_user_by_req(request, allow_expired=True)
87 | user_id = requester.user.to_string()
88 | except InvalidClientCredentialsError:
89 | user_id = None
90 |
91 | (
92 | token_valid,
93 | token_stale,
94 | expiration_ts,
95 | ) = await self.renew_account(renewal_token, user_id)
96 |
97 | if token_valid:
98 | status_code = 200
99 | response = self._account_renewed_template.render(
100 | expiration_ts=expiration_ts
101 | )
102 | elif token_stale:
103 | status_code = 200
104 | response = self._account_previously_renewed_template.render(
105 | expiration_ts=expiration_ts
106 | )
107 | else:
108 | status_code = 400
109 | response = self._invalid_token_template.render()
110 |
111 | respond_with_html(request, status_code, response)
112 |
113 |
114 | class EmailAccountValiditySendMailServlet(
115 | EmailAccountValidityBase,
116 | DirectServeJsonResource,
117 | ):
118 | def __init__(
119 | self,
120 | config: EmailAccountValidityConfig,
121 | api: ModuleApi,
122 | store: EmailAccountValidityStore,
123 | ):
124 | EmailAccountValidityBase.__init__(self, config, api, store)
125 | DirectServeJsonResource.__init__(self)
126 |
127 | if not api.public_baseurl:
128 | raise ConfigError("Can't send renewal emails without 'public_baseurl'")
129 |
130 | async def _async_render_POST(self, request):
131 | """On POST requests on /send_mail, send a renewal email to the account the access
132 | token authenticating the request belongs to.
133 | """
134 | requester = await self._api.get_user_by_req(request, allow_expired=True)
135 | user_id = requester.user.to_string()
136 | await self.send_renewal_email_to_user(user_id)
137 |
138 | return 200, {}
139 |
140 |
141 | class EmailAccountValidityAdminServlet(
142 | EmailAccountValidityBase,
143 | DirectServeJsonResource,
144 | ):
145 | async def _async_render_POST(self, request):
146 | """On POST requests on /admin, update the given user with the given account
147 | validity state, if the requester is a server admin.
148 | """
149 | requester = await self._api.get_user_by_req(request)
150 | if not await self._api.is_user_admin(requester.user.to_string()):
151 | raise SynapseError(403, "You are not a server admin", "M_FORBIDDEN")
152 |
153 | expiration_ts = await self.set_account_validity_from_request(request)
154 |
155 | res = {"expiration_ts": expiration_ts}
156 | return 200, res
157 |
--------------------------------------------------------------------------------
/email_account_validity/_store.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright 2021 The Matrix.org Foundation C.I.C.
3 | #
4 | # Licensed under the Apache License, Version 2.0 (the "License");
5 | # you may not use this file except in compliance with the License.
6 | # You may obtain a copy of the License at
7 | #
8 | # http://www.apache.org/licenses/LICENSE-2.0
9 | #
10 | # Unless required by applicable law or agreed to in writing, software
11 | # distributed under the License is distributed on an "AS IS" BASIS,
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | # See the License for the specific language governing permissions and
14 | # limitations under the License.
15 |
16 | import logging
17 | import random
18 | import time
19 | from typing import Dict, List, Optional, Tuple, Union
20 |
21 | from synapse.module_api import DatabasePool, LoggingTransaction, ModuleApi, cached
22 | from synapse.module_api.errors import SynapseError
23 |
24 | from email_account_validity._config import EmailAccountValidityConfig
25 | from email_account_validity._utils import TokenFormat
26 |
27 | logger = logging.getLogger(__name__)
28 |
29 | # The name of the column to look at for each type of renewal token.
30 | _TOKEN_COLUMN_NAME = {
31 | TokenFormat.LONG: "long_renewal_token",
32 | TokenFormat.SHORT: "short_renewal_token",
33 | }
34 |
35 |
36 | class EmailAccountValidityStore:
37 | def __init__(self, config: EmailAccountValidityConfig, api: ModuleApi):
38 | self._api = api
39 | self._period = config.period
40 | self._renew_at = config.renew_at
41 | self._expiration_ts_max_delta = self._period * 10.0 / 100.0
42 | self._rand = random.SystemRandom()
43 |
44 | self._api.register_cached_function(self.get_expiration_ts_for_user)
45 |
46 | async def create_and_populate_table(self, populate_users: bool = True):
47 | """Create the email_account_validity table and populate it from other tables from
48 | within Synapse. It populates users in it by batches of 100 in order not to clog up
49 | the database connection with big requests.
50 | """
51 | def create_table_txn(txn: LoggingTransaction):
52 | # Try to create a table for the module.
53 |
54 | # The table we create has the following columns:
55 | #
56 | # * user_id: The user's Matrix ID.
57 | # * expiration_ts_ms: The expiration timestamp for this user in milliseconds.
58 | # * email_sent: Whether a renewal email has already been sent to this user
59 | # * long_renewal_token: Long renewal tokens, which are unique to the whole
60 | # table, so that renewing an account using one doesn't
61 | # require further authentication.
62 | # * short_renewal_token: Short renewal tokens, which aren't unique to the
63 | # whole table, and with which renewing an account
64 | # requires authentication using an access token.
65 | # * token_used_ts_ms: Timestamp at which the renewal token for the user has
66 | # been used, or NULL if it hasn't been used yet.
67 | txn.execute(
68 | """
69 | CREATE TABLE IF NOT EXISTS email_account_validity(
70 | user_id TEXT PRIMARY KEY,
71 | expiration_ts_ms BIGINT NOT NULL,
72 | email_sent BOOLEAN NOT NULL,
73 | long_renewal_token TEXT,
74 | short_renewal_token TEXT,
75 | token_used_ts_ms BIGINT
76 | )
77 | """,
78 | (),
79 | )
80 |
81 | txn.execute(
82 | """
83 | CREATE UNIQUE INDEX IF NOT EXISTS long_renewal_token_idx
84 | ON email_account_validity(long_renewal_token)
85 | """,
86 | (),
87 | )
88 |
89 | txn.execute(
90 | """
91 | CREATE UNIQUE INDEX IF NOT EXISTS short_renewal_token_idx
92 | ON email_account_validity(short_renewal_token, user_id)
93 | """,
94 | (),
95 | )
96 |
97 | def populate_table_txn(txn: LoggingTransaction, batch_size: int) -> int:
98 | # Populate the database with the users that are in the users table but not in
99 | # the email_account_validity one.
100 | txn.execute(
101 | """
102 | SELECT users.name FROM users
103 | LEFT JOIN email_account_validity
104 | ON (users.name = email_account_validity.user_id)
105 | WHERE email_account_validity.user_id IS NULL
106 | LIMIT ?
107 | """,
108 | (batch_size,),
109 | )
110 |
111 | missing_users = DatabasePool.cursor_to_dict(txn)
112 | if not missing_users:
113 | return 0
114 |
115 | # Figure out the state of these users in the account_validity table.
116 | # Note that at some point we'll want to get rid of the account_validity table
117 | # and we'll need to get rid of this code as well.
118 | rows = DatabasePool.simple_select_many_txn(
119 | txn=txn,
120 | table="account_validity",
121 | column="user_id",
122 | iterable=tuple([user["name"] for user in missing_users]),
123 | keyvalues={},
124 | retcols=(
125 | "user_id",
126 | "expiration_ts_ms",
127 | "email_sent",
128 | "renewal_token",
129 | "token_used_ts_ms",
130 | ),
131 | )
132 |
133 | # Turn the results into a dictionary so we can later merge it with the list
134 | # of registered users on the homeserver.
135 | users_to_insert = {}
136 | for row in rows:
137 | users_to_insert[row["user_id"]] = row
138 |
139 | # Look for users that are registered but don't have a state in the
140 | # account_validity table, and set a default state for them. This default
141 | # state includes an expiration timestamp close to now + validity period, but
142 | # is slightly randomised to avoid sending huge bursts of renewal emails at
143 | # once.
144 | default_expiration_ts = int(time.time() * 1000) + self._period
145 | for user in missing_users:
146 | if users_to_insert.get(user["name"]) is None:
147 | users_to_insert[user["name"]] = {
148 | "user_id": user["name"],
149 | "expiration_ts_ms": self._rand.randrange(
150 | default_expiration_ts - self._expiration_ts_max_delta,
151 | default_expiration_ts,
152 | ),
153 | "email_sent": False,
154 | "renewal_token": None,
155 | "token_used_ts_ms": None,
156 | }
157 |
158 | # Insert the users in the table.
159 | DatabasePool.simple_insert_many_txn(
160 | txn=txn,
161 | table="email_account_validity",
162 | keys=[
163 | "user_id",
164 | "expiration_ts_ms",
165 | "email_sent",
166 | "long_renewal_token",
167 | "token_used_ts_ms",
168 | ],
169 | values=[
170 | (
171 | user["user_id"],
172 | user["expiration_ts_ms"],
173 | user["email_sent"],
174 | # If there's a renewal token for the user, we consider it's a long
175 | # one, because the non-module implementation of account validity
176 | # doesn't have a concept of short tokens.
177 | user["renewal_token"],
178 | user["token_used_ts_ms"],
179 | )
180 | for user in users_to_insert.values()
181 | ],
182 | )
183 |
184 | return len(missing_users)
185 |
186 | await self._api.run_db_interaction(
187 | "account_validity_create_table",
188 | create_table_txn,
189 | )
190 |
191 | if populate_users:
192 | batch_size = 100
193 | processed_rows = 100
194 | while processed_rows == batch_size:
195 | processed_rows = await self._api.run_db_interaction(
196 | "account_validity_populate_table",
197 | populate_table_txn,
198 | batch_size,
199 | )
200 | logger.info(
201 | "Inserted %s users in the email account validity table",
202 | processed_rows,
203 | )
204 |
205 | async def get_users_expiring_soon(self) -> List[Dict[str, Union[str, int]]]:
206 | """Selects users whose account will expire in the [now, now + renew_at] time
207 | window (see configuration for account_validity for information on what renew_at
208 | refers to).
209 |
210 | Returns:
211 | A list of dictionaries, each with a user ID and expiration time (in
212 | milliseconds).
213 | """
214 | def select_users_txn(txn, renew_at):
215 | now_ms = int(time.time() * 1000)
216 |
217 | txn.execute(
218 | """
219 | SELECT user_id, expiration_ts_ms FROM email_account_validity
220 | WHERE email_sent = ? AND (expiration_ts_ms - ?) <= ?
221 | """,
222 | (False, now_ms, renew_at),
223 | )
224 | return DatabasePool.cursor_to_dict(txn)
225 |
226 | return await self._api.run_db_interaction(
227 | "get_users_expiring_soon",
228 | select_users_txn,
229 | self._renew_at,
230 | )
231 |
232 | async def set_account_validity_for_user(
233 | self,
234 | user_id: str,
235 | expiration_ts: int,
236 | email_sent: bool,
237 | token_format: TokenFormat,
238 | renewal_token: Optional[str] = None,
239 | token_used_ts: Optional[int] = None,
240 | ):
241 | """Updates the account validity properties of the given account, with the
242 | given values.
243 |
244 | Args:
245 | user_id: ID of the account to update properties for.
246 | expiration_ts: New expiration date, as a timestamp in milliseconds
247 | since epoch.
248 | email_sent: True means a renewal email has been sent for this account
249 | and there's no need to send another one for the current validity
250 | period.
251 | token_format: The configured token format, used to determine which
252 | column to update.
253 | renewal_token: Renewal token the user can use to extend the validity
254 | of their account. Defaults to no token.
255 | token_used_ts: A timestamp of when the current token was used to renew
256 | the account.
257 | """
258 |
259 | def set_account_validity_for_user_txn(txn: LoggingTransaction):
260 | txn.execute(
261 | """
262 | INSERT INTO email_account_validity (
263 | user_id,
264 | expiration_ts_ms,
265 | email_sent,
266 | %(token_column_name)s,
267 | token_used_ts_ms
268 | )
269 | VALUES (?, ?, ?, ?, ?)
270 | ON CONFLICT (user_id) DO UPDATE
271 | SET
272 | expiration_ts_ms = EXCLUDED.expiration_ts_ms,
273 | email_sent = EXCLUDED.email_sent,
274 | %(token_column_name)s = EXCLUDED.%(token_column_name)s,
275 | token_used_ts_ms = EXCLUDED.token_used_ts_ms
276 | """ % {"token_column_name": _TOKEN_COLUMN_NAME[token_format]},
277 | (user_id, expiration_ts, email_sent, renewal_token, token_used_ts)
278 | )
279 |
280 | await self._api.run_db_interaction(
281 | "set_account_validity_for_user",
282 | set_account_validity_for_user_txn,
283 | )
284 |
285 | await self._api.invalidate_cache(self.get_expiration_ts_for_user, (user_id,))
286 |
287 | @cached()
288 | async def get_expiration_ts_for_user(self, user_id: str) -> int:
289 | """Get the expiration timestamp for the account bearing a given user ID.
290 |
291 | Args:
292 | user_id: The ID of the user.
293 | Returns:
294 | None, if the account has no expiration timestamp, otherwise int
295 | representation of the timestamp (as a number of milliseconds since epoch).
296 | """
297 |
298 | def get_expiration_ts_for_user_txn(txn: LoggingTransaction):
299 | return DatabasePool.simple_select_one_onecol_txn(
300 | txn=txn,
301 | table="email_account_validity",
302 | keyvalues={"user_id": user_id},
303 | retcol="expiration_ts_ms",
304 | allow_none=True,
305 | )
306 |
307 | res = await self._api.run_db_interaction(
308 | "get_expiration_ts_for_user",
309 | get_expiration_ts_for_user_txn,
310 | )
311 | return res
312 |
313 | async def set_renewal_token_for_user(
314 | self,
315 | user_id: str,
316 | renewal_token: str,
317 | token_format: TokenFormat,
318 | ):
319 | """Store the given renewal token for the given user.
320 |
321 | Args:
322 | user_id: The user ID to store the renewal token for.
323 | renewal_token: The renewal token to store for the user.
324 | token_format: The configured token format, used to determine which
325 | column to update.
326 | """
327 | def set_renewal_token_for_user_txn(txn: LoggingTransaction):
328 | # We don't need to check if the token is unique since we've got unique
329 | # indexes to check that.
330 | try:
331 | DatabasePool.simple_update_one_txn(
332 | txn=txn,
333 | table="email_account_validity",
334 | keyvalues={"user_id": user_id},
335 | updatevalues={
336 | _TOKEN_COLUMN_NAME[token_format]: renewal_token,
337 | "token_used_ts_ms": None,
338 | },
339 | )
340 | except Exception:
341 | raise SynapseError(500, "Failed to update renewal token")
342 |
343 | await self._api.run_db_interaction(
344 | "set_renewal_token_for_user",
345 | set_renewal_token_for_user_txn,
346 | )
347 |
348 | async def validate_renewal_token(
349 | self,
350 | renewal_token: str,
351 | token_format: TokenFormat,
352 | user_id: Optional[str] = None,
353 | ) -> Tuple[str, int, Optional[int]]:
354 | """Check if the provided renewal token is associating with a user, optionally
355 | validating the user it belongs to as well, and return the account renewal status
356 | of the user it belongs to.
357 |
358 | Args:
359 | renewal_token: The renewal token to perform the lookup with.
360 | token_format: The configured token format, used to determine which
361 | column to update.
362 | user_id: The Matrix ID of the user to renew, if the renewal request was
363 | authenticated.
364 |
365 | Returns:
366 | A tuple of containing the following values:
367 | * The ID of a user to which the token belongs.
368 | * An int representing the user's expiry timestamp as milliseconds since
369 | the epoch, or 0 if the token was invalid.
370 | * An optional int representing the timestamp of when the user renewed
371 | their account timestamp as milliseconds since the epoch. None if the
372 | account has not been renewed using the current token yet.
373 |
374 | Raises:
375 | StoreError(404): The token could not be found (or does not belong to the
376 | provided user, if any).
377 | """
378 |
379 | def get_user_from_renewal_token_txn(txn: LoggingTransaction):
380 | keyvalues = {_TOKEN_COLUMN_NAME[token_format]: renewal_token}
381 | if user_id is not None:
382 | keyvalues["user_id"] = user_id
383 |
384 | return DatabasePool.simple_select_one_txn(
385 | txn=txn,
386 | table="email_account_validity",
387 | keyvalues=keyvalues,
388 | retcols=["user_id", "expiration_ts_ms", "token_used_ts_ms"],
389 | )
390 |
391 | res = await self._api.run_db_interaction(
392 | "get_user_from_renewal_token",
393 | get_user_from_renewal_token_txn,
394 | )
395 |
396 | return res["user_id"], res["expiration_ts_ms"], res["token_used_ts_ms"]
397 |
398 | async def set_expiration_date_for_user(self, user_id: str):
399 | """Sets an expiration date to the account with the given user ID.
400 |
401 | Args:
402 | user_id: User ID to set an expiration date for.
403 | """
404 | await self._api.run_db_interaction(
405 | "set_expiration_date_for_user",
406 | self.set_expiration_date_for_user_txn,
407 | user_id,
408 | )
409 |
410 | def set_expiration_date_for_user_txn(
411 | self,
412 | txn: LoggingTransaction,
413 | user_id: str,
414 | ):
415 | """Sets an expiration date to the account with the given user ID.
416 |
417 | Args:
418 | user_id: User ID to set an expiration date for.
419 | """
420 | now_ms = int(time.time() * 1000)
421 | expiration_ts = now_ms + self._period
422 |
423 | sql = """
424 | INSERT INTO email_account_validity (user_id, expiration_ts_ms, email_sent)
425 | VALUES (?, ?, ?)
426 | ON CONFLICT (user_id) DO
427 | UPDATE SET
428 | expiration_ts_ms = EXCLUDED.expiration_ts_ms,
429 | email_sent = EXCLUDED.email_sent
430 | """
431 |
432 | txn.execute(sql, (user_id, expiration_ts, False))
433 |
434 | txn.call_after(self.get_expiration_ts_for_user.invalidate, (user_id,))
435 |
436 | async def set_renewal_mail_status(self, user_id: str, email_sent: bool) -> None:
437 | """Sets or unsets the flag that indicates whether a renewal email has been sent
438 | to the user (and the user hasn't renewed their account yet).
439 |
440 | Args:
441 | user_id: ID of the user to set/unset the flag for.
442 | email_sent: Flag which indicates whether a renewal email has been sent
443 | to this user.
444 | """
445 |
446 | def set_renewal_mail_status_txn(txn: LoggingTransaction):
447 | DatabasePool.simple_update_one_txn(
448 | txn=txn,
449 | table="email_account_validity",
450 | keyvalues={"user_id": user_id},
451 | updatevalues={"email_sent": email_sent},
452 | )
453 |
454 | await self._api.run_db_interaction(
455 | "set_renewal_mail_status",
456 | set_renewal_mail_status_txn,
457 | )
458 |
459 | async def get_renewal_token_for_user(
460 | self,
461 | user_id: str,
462 | token_format: TokenFormat,
463 | ) -> str:
464 | """Retrieve the renewal token for the given user.
465 |
466 | Args:
467 | user_id: Matrix ID of the user to retrieve the renewal token of.
468 | token_format: The configured token format, used to determine which
469 | column to update.
470 |
471 | Returns:
472 | The renewal token for the user.
473 | """
474 |
475 | def get_renewal_token_txn(txn: LoggingTransaction):
476 | return DatabasePool.simple_select_one_onecol_txn(
477 | txn=txn,
478 | table="email_account_validity",
479 | keyvalues={"user_id": user_id},
480 | retcol=_TOKEN_COLUMN_NAME[token_format],
481 | )
482 |
483 | return await self._api.run_db_interaction(
484 | "get_renewal_token_for_user",
485 | get_renewal_token_txn,
486 | )
487 |
--------------------------------------------------------------------------------
/email_account_validity/_utils.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright 2021 The Matrix.org Foundation C.I.C.
3 | #
4 | # Licensed under the Apache License, Version 2.0 (the "License");
5 | # you may not use this file except in compliance with the License.
6 | # You may obtain a copy of the License at
7 | #
8 | # http://www.apache.org/licenses/LICENSE-2.0
9 | #
10 | # Unless required by applicable law or agreed to in writing, software
11 | # distributed under the License is distributed on an "AS IS" BASIS,
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | # See the License for the specific language governing permissions and
14 | # limitations under the License.
15 |
16 | import re
17 | import secrets
18 | import string
19 | from typing import Union
20 |
21 |
22 | LONG_TOKEN_REGEX = re.compile('^[a-zA-Z]{32}$')
23 | SHORT_TOKEN_REGEX = re.compile('^[0-9]{8}$')
24 |
25 |
26 | class TokenFormat:
27 | """Supported formats for renewal tokens."""
28 |
29 | # A LONG renewal token is a 32 letter-long string.
30 | LONG = "long"
31 | # A SHORT renewal token is a 8 digit-long string.
32 | SHORT = "short"
33 |
34 |
35 | def random_digit_string(length):
36 | return "".join(secrets.choice(string.digits) for _ in range(length))
37 |
38 |
39 | def parse_duration(value: Union[str, int]) -> int:
40 | """Convert a duration as a string or integer to a number of milliseconds.
41 |
42 | If an integer is provided it is treated as milliseconds and is unchanged.
43 |
44 | String durations can have a suffix of 's', 'm', 'h', 'd', 'w', or 'y'.
45 | No suffix is treated as milliseconds.
46 |
47 | Args:
48 | value: The duration to parse.
49 |
50 | Returns:
51 | The number of milliseconds in the duration.
52 | """
53 | if isinstance(value, int):
54 | return value
55 | second = 1000
56 | minute = 60 * second
57 | hour = 60 * minute
58 | day = 24 * hour
59 | week = 7 * day
60 | year = 365 * day
61 | sizes = {"s": second, "m": minute, "h": hour, "d": day, "w": week, "y": year}
62 | size = 1
63 | suffix = value[-1]
64 | if suffix in sizes:
65 | value = value[:-1]
66 | size = sizes[suffix]
67 | return int(value) * size
68 |
69 |
70 | def random_string(length: int) -> str:
71 | """Generate a cryptographically secure string of random letters.
72 |
73 | Drawn from the characters: `a-z` and `A-Z`
74 | """
75 | return "".join(secrets.choice(string.ascii_letters) for _ in range(length))
76 |
--------------------------------------------------------------------------------
/email_account_validity/account_validity.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright 2021 The Matrix.org Foundation C.I.C.
3 | #
4 | # Licensed under the Apache License, Version 2.0 (the "License");
5 | # you may not use this file except in compliance with the License.
6 | # You may obtain a copy of the License at
7 | #
8 | # http://www.apache.org/licenses/LICENSE-2.0
9 | #
10 | # Unless required by applicable law or agreed to in writing, software
11 | # distributed under the License is distributed on an "AS IS" BASIS,
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | # See the License for the specific language governing permissions and
14 | # limitations under the License.
15 |
16 | import logging
17 | import time
18 | from typing import Tuple, Optional
19 |
20 | from twisted.web.server import Request
21 |
22 | from synapse.module_api import ModuleApi, run_in_background
23 | from synapse.module_api.errors import ConfigError
24 |
25 | from email_account_validity._base import EmailAccountValidityBase
26 | from email_account_validity._config import EmailAccountValidityConfig
27 | from email_account_validity._servlets import EmailAccountValidityServlet
28 | from email_account_validity._store import EmailAccountValidityStore
29 | from email_account_validity._utils import parse_duration
30 |
31 | logger = logging.getLogger(__name__)
32 |
33 |
34 | class EmailAccountValidity(EmailAccountValidityBase):
35 | def __init__(
36 | self,
37 | config: EmailAccountValidityConfig,
38 | api: ModuleApi,
39 | populate_users: bool = True,
40 | ):
41 | if not api.public_baseurl:
42 | raise ConfigError("Can't send renewal emails without 'public_baseurl'")
43 |
44 | self._store = EmailAccountValidityStore(config, api)
45 | self._api = api
46 |
47 | super().__init__(config, self._api, self._store)
48 |
49 | run_in_background(self._store.create_and_populate_table, populate_users)
50 | self._api.looping_background_call(
51 | self._send_renewal_emails, 30 * 60 * 1000
52 | )
53 |
54 | self._api.register_account_validity_callbacks(
55 | is_user_expired=self.is_user_expired,
56 | on_user_registration=self.on_user_registration,
57 | on_legacy_send_mail=self.on_legacy_send_mail,
58 | on_legacy_renew=self.on_legacy_renew,
59 | on_legacy_admin_request=self.on_legacy_admin_request,
60 | )
61 |
62 | self._api.register_web_resource(
63 | path="/_synapse/client/email_account_validity",
64 | resource=EmailAccountValidityServlet(config, self._api, self._store)
65 | )
66 |
67 | @staticmethod
68 | def parse_config(config: dict):
69 | """Check that the configuration includes the required keys and parse the values
70 | expressed as durations."""
71 | if "period" not in config:
72 | raise ConfigError("'period' is required when using email account validity")
73 |
74 | if "renew_at" not in config:
75 | raise ConfigError(
76 | "'renew_at' is required when using email account validity"
77 | )
78 |
79 | parsed_config = EmailAccountValidityConfig(
80 | period=parse_duration(config["period"]),
81 | renew_at=parse_duration(config["renew_at"]),
82 | renew_email_subject=config.get("renew_email_subject"),
83 | send_links=config.get("send_links", True)
84 | )
85 | return parsed_config
86 |
87 | async def on_legacy_renew(self, renewal_token: str) -> Tuple[bool, bool, int]:
88 | """Attempt to renew an account and return the results of this attempt to the
89 | deprecated /renew servlet.
90 |
91 | Args:
92 | renewal_token: Token sent with the renewal request.
93 |
94 | Returns:
95 | A tuple containing:
96 | * A bool representing whether the token is valid and unused.
97 | * A bool which is `True` if the token is valid, but stale.
98 | * An int representing the user's expiry timestamp as milliseconds since the
99 | epoch, or 0 if the token was invalid.
100 | """
101 | return await self.renew_account(renewal_token)
102 |
103 | async def on_legacy_send_mail(self, user_id: str):
104 | """Sends a renewal email to the addresses associated with the given Matrix user
105 | ID.
106 |
107 | Args:
108 | user_id: The user ID to send a renewal email for.
109 | """
110 | await self.send_renewal_email_to_user(user_id)
111 |
112 | async def on_legacy_admin_request(self, request: Request) -> int:
113 | """Update the account validity state of a user using the data from the given
114 | request.
115 |
116 | Args:
117 | request: The request to extract data from.
118 |
119 | Returns:
120 | The new expiration timestamp for the updated user.
121 | """
122 | return await self.set_account_validity_from_request(request)
123 |
124 | async def is_user_expired(self, user_id: str) -> Optional[bool]:
125 | """Checks whether a user is expired.
126 |
127 | Args:
128 | user_id: The user to check the expiration state for.
129 |
130 | Returns:
131 | A boolean indicating if the user has expired, or None if the module could not
132 | figure it out (i.e. if the user has no expiration timestamp).
133 | """
134 | expiration_ts = await self._store.get_expiration_ts_for_user(user_id)
135 | if expiration_ts is None:
136 | return None
137 |
138 | now_ts = int(time.time() * 1000)
139 | return now_ts >= expiration_ts
140 |
141 | async def on_user_registration(self, user_id: str):
142 | """Set the expiration timestamp for a newly registered user.
143 |
144 | Args:
145 | user_id: The ID of the newly registered user to set an expiration date for.
146 | """
147 | await self._store.set_expiration_date_for_user(user_id)
148 |
149 | async def _send_renewal_emails(self):
150 | """Gets the list of users whose account is expiring in the amount of time
151 | configured in the ``renew_at`` parameter from the ``account_validity``
152 | configuration, and sends renewal emails to all of these users as long as they
153 | have an email 3PID attached to their account.
154 | """
155 | expiring_users = await self._store.get_users_expiring_soon()
156 |
157 | if expiring_users:
158 | for user in expiring_users:
159 | if user["expiration_ts_ms"] is None:
160 | logger.warning(
161 | "User %s has no expiration ts, ignoring" % user["user_id"],
162 | )
163 | continue
164 |
165 | await self.send_renewal_email(
166 | user_id=user["user_id"], expiration_ts=user["expiration_ts_ms"]
167 | )
168 |
--------------------------------------------------------------------------------
/email_account_validity/templates/account_previously_renewed.html:
--------------------------------------------------------------------------------
1 |
Your account is valid until {{ expiration_ts|format_ts("%d-%m-%Y") }}.
2 |
--------------------------------------------------------------------------------
/email_account_validity/templates/account_renewed.html:
--------------------------------------------------------------------------------
1 | Your account has been successfully renewed and is valid until {{ expiration_ts|format_ts("%d-%m-%Y") }}.
2 |
--------------------------------------------------------------------------------
/email_account_validity/templates/invalid_token.html:
--------------------------------------------------------------------------------
1 | Invalid renewal token.
2 |
--------------------------------------------------------------------------------
/email_account_validity/templates/mail-expiry.css:
--------------------------------------------------------------------------------
1 | .noticetext {
2 | margin-top: 10px;
3 | margin-bottom: 10px;
4 | }
5 |
--------------------------------------------------------------------------------
/email_account_validity/templates/mail.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0px;
3 | }
4 |
5 | pre, code {
6 | word-break: break-word;
7 | white-space: pre-wrap;
8 | }
9 |
10 | #page {
11 | font-family: 'Open Sans', Helvetica, Arial, Sans-Serif;
12 | font-color: #454545;
13 | font-size: 12pt;
14 | width: 100%;
15 | padding: 20px;
16 | }
17 |
18 | #inner {
19 | width: 640px;
20 | }
21 |
22 | .header {
23 | width: 100%;
24 | height: 87px;
25 | color: #454545;
26 | border-bottom: 4px solid #e5e5e5;
27 | }
28 |
29 | .logo {
30 | text-align: right;
31 | margin-left: 20px;
32 | }
33 |
34 | .salutation {
35 | padding-top: 10px;
36 | font-weight: bold;
37 | }
38 |
39 | .summarytext {
40 | }
41 |
42 | .room {
43 | width: 100%;
44 | color: #454545;
45 | border-bottom: 1px solid #e5e5e5;
46 | }
47 |
48 | .room_header td {
49 | padding-top: 38px;
50 | padding-bottom: 10px;
51 | border-bottom: 1px solid #e5e5e5;
52 | }
53 |
54 | .room_name {
55 | vertical-align: middle;
56 | font-size: 18px;
57 | font-weight: bold;
58 | }
59 |
60 | .room_header h2 {
61 | margin-top: 0px;
62 | margin-left: 75px;
63 | font-size: 20px;
64 | }
65 |
66 | .room_avatar {
67 | width: 56px;
68 | line-height: 0px;
69 | text-align: center;
70 | vertical-align: middle;
71 | }
72 |
73 | .room_avatar img {
74 | width: 48px;
75 | height: 48px;
76 | object-fit: cover;
77 | border-radius: 24px;
78 | }
79 |
80 | .notif {
81 | border-bottom: 1px solid #e5e5e5;
82 | margin-top: 16px;
83 | padding-bottom: 16px;
84 | }
85 |
86 | .historical_message .sender_avatar {
87 | opacity: 0.3;
88 | }
89 |
90 | /* spell out opacity and historical_message class names for Outlook aka Word */
91 | .historical_message .sender_name {
92 | color: #e3e3e3;
93 | }
94 |
95 | .historical_message .message_time {
96 | color: #e3e3e3;
97 | }
98 |
99 | .historical_message .message_body {
100 | color: #c7c7c7;
101 | }
102 |
103 | .historical_message td,
104 | .message td {
105 | padding-top: 10px;
106 | }
107 |
108 | .sender_avatar {
109 | width: 56px;
110 | text-align: center;
111 | vertical-align: top;
112 | }
113 |
114 | .sender_avatar img {
115 | margin-top: -2px;
116 | width: 32px;
117 | height: 32px;
118 | border-radius: 16px;
119 | }
120 |
121 | .sender_name {
122 | display: inline;
123 | font-size: 13px;
124 | color: #a2a2a2;
125 | }
126 |
127 | .message_time {
128 | text-align: right;
129 | width: 100px;
130 | font-size: 11px;
131 | color: #a2a2a2;
132 | }
133 |
134 | .message_body {
135 | }
136 |
137 | .notif_link td {
138 | padding-top: 10px;
139 | padding-bottom: 10px;
140 | font-weight: bold;
141 | }
142 |
143 | .notif_link a, .footer a {
144 | color: #454545;
145 | text-decoration: none;
146 | }
147 |
148 | .debug {
149 | font-size: 10px;
150 | color: #888;
151 | }
152 |
153 | .footer {
154 | margin-top: 20px;
155 | text-align: center;
156 | }
--------------------------------------------------------------------------------
/email_account_validity/templates/notice_expiry.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
13 | |
14 |
15 |
40 | |
41 | |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/email_account_validity/templates/notice_expiry.txt:
--------------------------------------------------------------------------------
1 | Hi {{ display_name }},
2 |
3 | Your account will expire on {{ expiration_ts|format_ts("%d-%m-%Y") }}. This means that you will lose access to your account after this date.
4 |
5 | To extend the validity of your account, please click on the link below (or copy and paste it to a new browser tab):
6 |
7 | {{ url }}
8 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | # Copyright 2021 The Matrix.org Foundation C.I.C.
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 | from setuptools import setup
18 | from codecs import open
19 | import os
20 |
21 | here = os.path.abspath(os.path.dirname(__file__))
22 |
23 |
24 | def read_file(path_segments):
25 | """Read a UTF-8 file from the package. Takes a list of strings to join to
26 | make the path"""
27 | file_path = os.path.join(here, *path_segments)
28 | with open(file_path, encoding="utf-8") as f:
29 | return f.read()
30 |
31 |
32 | def exec_file(path_segments, name):
33 | """Extract a constant from a python file by looking for a line defining
34 | the constant and executing it."""
35 | result = {}
36 | code = read_file(path_segments)
37 | lines = [line for line in code.split("\n") if line.startswith(name)]
38 | exec("\n".join(lines), result)
39 | return result[name]
40 |
41 |
42 | setup(
43 | name="synapse-email-account-validity",
44 | packages=["email_account_validity"],
45 | include_package_data=True,
46 | description="An account validity module for Synapse using email",
47 | use_scm_version=True,
48 | setup_requires=["setuptools_scm"],
49 | long_description=read_file(("README.md",)),
50 | long_description_content_type="text/markdown",
51 | url="https://github.com/matrix-org/synapse-email-account-validity",
52 | python_requires=">=3.6",
53 | classifiers=[
54 | "Development Status :: 4 - Beta",
55 | "License :: OSI Approved :: Apache Software License",
56 | "Programming Language :: Python :: 3",
57 | ],
58 | )
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright 2021 The Matrix.org Foundation C.I.C.
3 | #
4 | # Licensed under the Apache License, Version 2.0 (the "License");
5 | # you may not use this file except in compliance with the License.
6 | # You may obtain a copy of the License at
7 | #
8 | # http://www.apache.org/licenses/LICENSE-2.0
9 | #
10 | # Unless required by applicable law or agreed to in writing, software
11 | # distributed under the License is distributed on an "AS IS" BASIS,
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | # See the License for the specific language governing permissions and
14 | # limitations under the License.
15 |
16 | from collections import namedtuple
17 | import sqlite3
18 | import time
19 | from unittest import mock
20 |
21 | import jinja2
22 | from synapse.module_api import ModuleApi
23 |
24 | from email_account_validity import EmailAccountValidity
25 |
26 |
27 | class SQLiteStore:
28 | """In-memory SQLite store. We can't just use a run_db_interaction function that opens
29 | its own connection, since we need to use the same connection for all queries in a
30 | test.
31 | """
32 | def __init__(self):
33 | self.conn = sqlite3.connect(":memory:")
34 |
35 | async def run_db_interaction(self, desc, f, *args, **kwargs):
36 | cur = CursorWrapper(self.conn.cursor())
37 | try:
38 | res = f(cur, *args, **kwargs)
39 | self.conn.commit()
40 | return res
41 | except Exception:
42 | self.conn.rollback()
43 | raise
44 |
45 |
46 | class CursorWrapper:
47 | """Wrapper around a SQLite cursor that also provides a call_after method."""
48 | def __init__(self, cursor: sqlite3.Cursor):
49 | self.cur = cursor
50 |
51 | def execute(self, sql, args):
52 | self.cur.execute(sql, args)
53 |
54 | @property
55 | def rowcount(self):
56 | return self.cur.rowcount
57 |
58 | def fetchone(self):
59 | return self.cur.fetchone()
60 |
61 | def call_after(self, f, args):
62 | f(args)
63 |
64 | def __iter__(self):
65 | return self.cur.__iter__()
66 |
67 | def __next__(self):
68 | return self.cur.__next__()
69 |
70 |
71 | def read_templates(filenames, directory):
72 | """Reads Jinja templates from the templates directory. This function is mostly copied
73 | from Synapse.
74 | """
75 | loader = jinja2.FileSystemLoader(directory)
76 | env = jinja2.Environment(
77 | loader=loader,
78 | autoescape=jinja2.select_autoescape(),
79 | )
80 |
81 | def _format_ts_filter(value: int, format: str):
82 | return time.strftime(format, time.localtime(value / 1000))
83 |
84 | env.filters.update(
85 | {
86 | "format_ts": _format_ts_filter,
87 | }
88 | )
89 |
90 | return [env.get_template(filename) for filename in filenames]
91 |
92 |
93 | async def get_profile_for_user(user_id):
94 | ProfileInfo = namedtuple("ProfileInfo", ("avatar_url", "display_name"))
95 | return ProfileInfo(None, "Izzy")
96 |
97 |
98 | async def send_mail(recipient, subject, html, text):
99 | return None
100 |
101 |
102 | async def invalidate_cache(cached_func, keys):
103 | cached_func.invalidate(keys)
104 |
105 |
106 | async def create_account_validity_module(config={}) -> EmailAccountValidity:
107 | """Starts an EmailAccountValidity module with a basic config and a mock of the
108 | ModuleApi.
109 | """
110 | config.update(
111 | {
112 | "period": "6w",
113 | "renew_at": "1w",
114 | }
115 | )
116 |
117 | store = SQLiteStore()
118 |
119 | # Create a mock based on the ModuleApi spec, but override some mocked functions
120 | # because some capabilities (interacting with the database, getting the current time,
121 | # etc.) are needed for running the tests.
122 | module_api = mock.Mock(spec=ModuleApi)
123 | module_api.run_db_interaction.side_effect = store.run_db_interaction
124 | module_api.read_templates.side_effect = read_templates
125 | module_api.get_profile_for_user.side_effect = get_profile_for_user
126 | module_api.send_mail.side_effect = send_mail
127 | module_api.invalidate_cache.side_effect = invalidate_cache
128 |
129 | # Make sure the table is created. Don't try to populate with users since we don't
130 | # have tables to populate from.
131 | parsed_config = EmailAccountValidity.parse_config(config)
132 | module = EmailAccountValidity(parsed_config, module_api, populate_users=False)
133 | await module._store.create_and_populate_table(populate_users=False)
134 |
135 | return module
136 |
--------------------------------------------------------------------------------
/tests/test_account_validity.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright 2021 The Matrix.org Foundation C.I.C.
3 | #
4 | # Licensed under the Apache License, Version 2.0 (the "License");
5 | # you may not use this file except in compliance with the License.
6 | # You may obtain a copy of the License at
7 | #
8 | # http://www.apache.org/licenses/LICENSE-2.0
9 | #
10 | # Unless required by applicable law or agreed to in writing, software
11 | # distributed under the License is distributed on an "AS IS" BASIS,
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | # See the License for the specific language governing permissions and
14 | # limitations under the License.
15 |
16 | import asyncio
17 | import time
18 |
19 | # From Python 3.8 onwards, aiounittest.AsyncTestCase can be replaced by
20 | # unittest.IsolatedAsyncioTestCase, so we'll be able to get rid of this dependency when
21 | # we stop supporting Python < 3.8 in Synapse.
22 | import aiounittest
23 |
24 | from synapse.module_api.errors import SynapseError
25 |
26 | from email_account_validity._utils import LONG_TOKEN_REGEX, SHORT_TOKEN_REGEX, TokenFormat
27 | from tests import create_account_validity_module
28 |
29 |
30 | class AccountValidityHooksTestCase(aiounittest.AsyncTestCase):
31 | async def test_user_expired(self):
32 | user_id = "@izzy:test"
33 | module = await create_account_validity_module()
34 |
35 | now_ms = int(time.time() * 1000)
36 | one_hour_ahead = now_ms + 3600000
37 | one_hour_ago = now_ms - 3600000
38 |
39 | # Test that, if the user isn't known, the module says it can't determine whether
40 | # they've expired.
41 | expired = await module.is_user_expired(user_id=user_id)
42 |
43 | self.assertIsNone(expired)
44 |
45 | # Test that, if the user has an expiration timestamp that's ahead of now, the
46 | # module says it can determine that they haven't expired.
47 | await module.renew_account_for_user(
48 | user_id=user_id,
49 | expiration_ts=one_hour_ahead,
50 | )
51 |
52 | expired = await module.is_user_expired(user_id=user_id)
53 | self.assertFalse(expired)
54 |
55 | # Test that, if the user has an expiration timestamp that's passed, the module
56 | # says it can determine that they have expired.
57 | await module.renew_account_for_user(
58 | user_id=user_id,
59 | expiration_ts=one_hour_ago,
60 | )
61 |
62 | expired = await module.is_user_expired(user_id=user_id)
63 | self.assertTrue(expired)
64 |
65 | async def test_on_user_registration(self):
66 | user_id = "@izzy:test"
67 | module = await create_account_validity_module()
68 |
69 | # Test that the user doesn't have an expiration date in the database. This acts
70 | # as a safeguard against old databases, and also adds an entry to the cache for
71 | # get_expiration_ts_for_user so we're sure later in the test that we've correctly
72 | # invalidated it.
73 | expiration_ts = await module._store.get_expiration_ts_for_user(user_id)
74 |
75 | self.assertIsNone(expiration_ts)
76 |
77 | # Call the registration hook and test that the user now has an expiration
78 | # timestamp that's ahead of now.
79 | await module.on_user_registration(user_id)
80 |
81 | expiration_ts = await module._store.get_expiration_ts_for_user(user_id)
82 | now_ms = int(time.time() * 1000)
83 |
84 | self.assertIsInstance(expiration_ts, int)
85 | self.assertGreater(expiration_ts, now_ms)
86 |
87 |
88 | class AccountValidityEmailTestCase(aiounittest.AsyncTestCase):
89 | async def test_send_email(self):
90 | user_id = "@izzy:test"
91 | module = await create_account_validity_module()
92 |
93 | # Set the side effect of get_threepids_for_user so that it returns a threepid on
94 | # the first call and an empty list on the second call.
95 |
96 | threepids = [{
97 | "medium": "email",
98 | "address": "izzy@test",
99 | }]
100 |
101 | async def get_threepids(user_id):
102 | return threepids
103 |
104 | module._api.get_threepids_for_user.side_effect = get_threepids
105 |
106 | # Test that trying to send an email to an unknown user doesn't result in an email
107 | # being sent.
108 | try:
109 | await module.send_renewal_email_to_user(user_id)
110 | except SynapseError:
111 | pass
112 |
113 | self.assertEqual(module._api.send_mail.call_count, 0)
114 |
115 | await module._store.set_expiration_date_for_user(user_id)
116 |
117 | # Test that trying to send an email to a known user that has an email address
118 | # attached to their account results in an email being sent
119 | await module.send_renewal_email_to_user(user_id)
120 | self.assertEqual(module._api.send_mail.call_count, 1)
121 |
122 | # Test that the email content contains a link; we haven't set send_links in the
123 | # module's config so its value should be the default (which is True).
124 | _, kwargs = module._api.send_mail.call_args
125 | path = "_synapse/client/email_account_validity/renew"
126 | self.assertNotEqual(kwargs["html"].find(path), -1)
127 | self.assertNotEqual(kwargs["text"].find(path), -1)
128 |
129 | # Test that trying to send an email to a known use that has no email address
130 | # attached to their account results in no email being sent.
131 | threepids = []
132 | await module.send_renewal_email_to_user(user_id)
133 | self.assertEqual(module._api.send_mail.call_count, 1)
134 |
135 | async def test_renewal_token(self):
136 | user_id = "@izzy:test"
137 | module = await create_account_validity_module()
138 |
139 | # Insert a row with an expiration timestamp and a renewal token for this user.
140 | await module._store.set_expiration_date_for_user(user_id)
141 | await module.generate_unauthenticated_renewal_token(user_id)
142 |
143 | # Retrieve the expiration timestamp and renewal token and check that they're in
144 | # the right format.
145 | old_expiration_ts = await module._store.get_expiration_ts_for_user(user_id)
146 | self.assertIsInstance(old_expiration_ts, int)
147 |
148 | renewal_token = await module._store.get_renewal_token_for_user(
149 | user_id,
150 | TokenFormat.LONG,
151 | )
152 | self.assertIsInstance(renewal_token, str)
153 | self.assertGreater(len(renewal_token), 0)
154 | self.assertTrue(LONG_TOKEN_REGEX.match(renewal_token))
155 |
156 | # Sleep a bit so the new expiration timestamp isn't likely to be equal to the
157 | # previous one.
158 | await asyncio.sleep(0.5)
159 |
160 | # Renew the account once with the token and test that the token is marked as
161 | # valid and the expiration timestamp has been updated.
162 | (
163 | token_valid,
164 | token_stale,
165 | new_expiration_ts,
166 | ) = await module.renew_account(renewal_token)
167 |
168 | self.assertTrue(token_valid)
169 | self.assertFalse(token_stale)
170 | self.assertGreater(new_expiration_ts, old_expiration_ts)
171 |
172 | # Renew the account a second time with the same token and test that, this time,
173 | # the token is marked as stale, and the expiration timestamp hasn't changed.
174 | (
175 | token_valid,
176 | token_stale,
177 | new_new_expiration_ts,
178 | ) = await module.renew_account(renewal_token)
179 |
180 | self.assertFalse(token_valid)
181 | self.assertTrue(token_stale)
182 | self.assertEqual(new_expiration_ts, new_new_expiration_ts)
183 |
184 | # Test that a fake token is marked as neither valid nor stale.
185 | (
186 | token_valid,
187 | token_stale,
188 | expiration_ts,
189 | ) = await module.renew_account("fake_token")
190 |
191 | self.assertFalse(token_valid)
192 | self.assertFalse(token_stale)
193 | self.assertEqual(expiration_ts, 0)
194 |
195 | async def test_duplicate_token(self):
196 | user_id_1 = "@izzy1:test"
197 | user_id_2 = "@izzy2:test"
198 | token = "sometoken"
199 |
200 | module = await create_account_validity_module()
201 |
202 | # Insert both users in the table.
203 | await module._store.set_expiration_date_for_user(user_id_1)
204 | await module._store.set_expiration_date_for_user(user_id_2)
205 |
206 | # Set the renewal token.
207 | await module._store.set_renewal_token_for_user(user_id_1, token, TokenFormat.LONG)
208 |
209 | # Try to set the same renewal token for another user.
210 | exception = None
211 | try:
212 | await module._store.set_renewal_token_for_user(
213 | user_id_2, token, TokenFormat.LONG,
214 | )
215 | except SynapseError as e:
216 | exception = e
217 |
218 | # Check that an exception was raised and that it's the one we're expecting.
219 | self.assertIsInstance(exception, SynapseError)
220 | self.assertEqual(exception.code, 500)
221 |
222 | async def test_send_link_false(self):
223 | user_id = "@izzy:test"
224 | # Create a module with a configuration forbidding it to send links via email.
225 | module = await create_account_validity_module({"send_links": False})
226 |
227 | async def get_threepids(user_id):
228 | return [{
229 | "medium": "email",
230 | "address": "izzy@test",
231 | }]
232 | module._api.get_threepids_for_user.side_effect = get_threepids
233 | await module._store.set_expiration_date_for_user(user_id)
234 |
235 | # Test that, when an email is sent, it doesn't include a link. We do this by
236 | # searching the email's content for the path for renewal requests.
237 | await module.send_renewal_email_to_user(user_id)
238 | self.assertEqual(module._api.send_mail.call_count, 1)
239 |
240 | _, kwargs = module._api.send_mail.call_args
241 | path = "_synapse/client/email_account_validity/renew"
242 | self.assertEqual(kwargs["html"].find(path), -1, kwargs["text"])
243 | self.assertEqual(kwargs["text"].find(path), -1, kwargs["text"])
244 |
245 | # Check that the renewal token is in the right format. It should be a 8 digit
246 | # long string.
247 | token = await module._store.get_renewal_token_for_user(user_id, TokenFormat.SHORT)
248 | self.assertIsInstance(token, str)
249 | self.assertTrue(SHORT_TOKEN_REGEX.match(token))
250 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = tests
3 |
4 | [testenv:tests]
5 | deps =
6 | matrix-synapse>=1.39.0
7 | aiounittest>=1.4.0
8 |
9 | commands =
10 | python -m unittest discover
--------------------------------------------------------------------------------