├── .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 | 41 | 42 | 43 |
15 | 16 | 17 | 20 | 31 | 32 | 33 | 38 | 39 |
18 |
Hi {{ display_name }},
19 |
34 |
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.
35 |
To extend the validity of your account, please click on the link below (or copy and paste it into a new browser tab):
36 | 37 |
40 |
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 --------------------------------------------------------------------------------