diff --git a/.gitignore b/.gitignore index b45fe6787..fb8fe405b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ kinto/plugins/admin/node_modules kinto/plugins/admin/coverage kinto/plugins/admin/npm-debug.log .pytest_cache/ +mail/ diff --git a/CHANGELOG.rst b/CHANGELOG.rst index acb442529..463b57e4a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -20,6 +20,20 @@ This document describes changes between each past release. - Fixed spelling and Filtering docs +**New features** + +- Expose the user_profile in the user field of the hello page. (#1989) +- Add an "account validation" option to the accounts plugin. (#1973) +- Add a ``validate`` endpoint at ``/accounts/{user id}/validate/{validation + key}`` which can be used to validate an account when the :ref:`account + validation ` option is enabled on the accounts plugin. +- Add a ``reset-password`` endpoint at ``/accounts/(user + id)/reset-password`` which can be used to reset a user's password when the + :ref:`account validation ` option is enabled on the + accounts plugin. + + +API is now at version **1.22**. See `API changelog`_. 13.0.1 (2019-01-29) ------------------- @@ -28,7 +42,6 @@ This document describes changes between each past release. - Loosen up the Content-Security policies in the Kinto Admin plugin to prevent Webpack inline script to be rejected (fixes #2000) - 13.0.0 (2019-01-25) ------------------- @@ -45,11 +58,6 @@ This document describes changes between each past release. - Upgrade kinto-admin to v1.23.0 -**New features** - -- Expose the user_profile in the user field of the hello page. (#1989) - - 12.0.2 (2019-01-25) ------------------- @@ -66,7 +74,6 @@ This document describes changes between each past release. - Fix bumping of tombstones timestamps when deleting objects in PostgreSQL storage backend (fixes #1981) - Fix ETag header in responses of DELETE on plural endpoints (ref #1981) - 12.0.0 (2019-01-10) ------------------- diff --git a/docs/api/1.x/accounts.rst b/docs/api/1.x/accounts.rst index e59b8eadd..05b7265a6 100644 --- a/docs/api/1.x/accounts.rst +++ b/docs/api/1.x/accounts.rst @@ -147,14 +147,23 @@ Alternatively, accounts can be created using POST. Supply the user id and passw .. sourcecode:: bash - $ echo '{"data": {"id": "bob", password": "azerty123"}}' | http POST http://localhost:8888/v1/accounts --verbose + $ echo '{"data": {"id": "bob", "password": "azerty123"}}' | http POST http://localhost:8888/v1/accounts --verbose .. note:: Depending on the :ref:`configuration `, you may not be allowed to create accounts. +.. note:: + + If the :ref:`accounts validation ` is enabled, + you might also need to provide an ``email-context`` in the ``data``: + + .. sourcecode:: bash + + $ echo '{"data": {"id": "bob@example.com", "password": "azerty123", "email-context": {"name": "Bob Smith", "form-url": "https://example.com/validate/"}}}' | http POST http://localhost:8888/v1/accounts --verbose + -.. _accounts-udpate: +.. _accounts-update: Change password =============== @@ -294,3 +303,125 @@ Or delete some account: :: $ http DELETE http://localhost:8888/v1/accounts/sam-body --auth admin:s3cr3t + + + +.. _accounts-validate: + +Validate accounts +================= + +When the ``account_validation`` option is enabled in :ref:`the settings +`, account IDs need to be valid email addresses: +they need to match the regexp in the ``account_validation.email_regexp`` +setting. The default one is very generic, but you may restrict it to only allow +certain emails, for example only ones from a specific domain. + +To make sure the ``account_validation`` is enabled, you can check if the +``validation_enabled`` flag is ``true`` in the ``"accounts"`` field on the +:ref:`root URL `. + +.. http:post:: /accounts/(user_id)/validate/(activation_key) + + :synopsis: Activates a newly created account with the ``account_validation`` option enabled. + + **Anonymous** + + **Example Request** + + .. sourcecode:: bash + + $ http POST http://localhost:8888/v1/accounts/bob@example.com/validate/2fe7a389-3556-4c8f-9513-c26bfc5f160b --verbose + + + .. sourcecode:: http + + POST /v1/accounts/bob@example.com/validate/2fe7a389-3556-4c8f-9513-c26bfc5f160b HTTP/1.1 + Accept: */* + Accept-Encoding: gzip, deflate + Connection: keep-alive + Content-Length: 0 + Host: localhost:8888 + User-Agent: HTTPie/0.9.8 + + **Example Response** + + .. sourcecode:: http + + HTTP/1.1 200 OK + Access-Control-Expose-Headers: Content-Length, Retry-After, Backoff, Alert + Content-Length: 195 + Content-Type: application/json + Date: Mon, 21 Jan 2019 13:41:17 GMT + Server: waitress + X-Content-Type-Options: nosniff + + { + "id": "bob@example.com", + "last_modified": 1548077982793, + "password": "$2b$12$zlTlYet5v.v57ak2gEYyoeqKSGzLvwXF/.v3DGpT/q69LecHv68gm", + "validated": true + } + +.. _accounts-reset-password: + +Resetting a forgotten password +============================== + +If the ``account_validation`` option in :ref:`the settings +` has been enabled, a temporary reset password may +be requested through the endpoint available at ``/accounts/(user +id)/reset-password``. + +.. http:post:: /accounts/(user_id)/reset-password + + :synopsis: Request a temporary reset password for an account with the ``account_validation`` option enabled. + + **Anonymous** + + **Example Request** + + .. sourcecode:: bash + + $ http POST http://localhost:8888/v1/accounts/bob@example.com/reset-password --verbose + + + .. sourcecode:: http + + POST /v1/accounts/bob@example.com/reset-password HTTP/1.1 + Accept: */* + Accept-Encoding: gzip, deflate + Connection: keep-alive + Content-Length: 0 + Host: localhost:8888 + User-Agent: HTTPie/0.9.8 + + + **Example Response** + + .. sourcecode:: http + + HTTP/1.1 200 OK + Access-Control-Expose-Headers: Backoff, Alert, Retry-After, Content-Length + Content-Length: 62 + Content-Type: application/json + Date: Fri, 08 Feb 2019 14:04:15 GMT + Server: waitress + X-Content-Type-Options: nosniff + + { + "message": "A temporary reset password has been sent by mail" + } + + .. note:: + + You might also need to provide an ``email-context`` in the ``data`` to fill + in the holes of the email template defined in the :ref:`settings + `: + + .. sourcecode:: bash + + $ echo '{"data": {"email-context": {"name": "Bob Smith"}}}' | http POST http://localhost:8888/v1/accounts/bob@example.com/reset-password --verbose + +Using this temporary reset password, one can +:ref:`update the account ` providing the new password. diff --git a/docs/api/index.rst b/docs/api/index.rst index 08134b249..be248c7b4 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -11,6 +11,17 @@ API Changelog --------- +1.22 (unreleased) +''''''''''''''''' + +- New ``/accounts/{user id}/validate/{validation key}`` endpoint to validate a + created account when the ``account validation`` option is enabled for the + accounts plugin. See :ref:`account validation ` (#1973) +- New ``/accounts/{user id}/reset-password`` endpoint to request a temporary + reset password by email when the ``account validation`` option is enabled for + the accounts plugin. See :ref:`account validation ` (#1973) + + 1.21 (2019-01-10) ''''''''''''''''' diff --git a/docs/configuration/settings.rst b/docs/configuration/settings.rst index 5d5333341..cb6896a1c 100644 --- a/docs/configuration/settings.rst +++ b/docs/configuration/settings.rst @@ -569,6 +569,150 @@ You can set ``account_create_principals`` if you want to limit account creation See the :ref:`API docs ` to create accounts, change passwords etc. +.. _settings-account-validation: + +**About account validation** + +You can enable the :ref:`account validation ` option, which +will require account IDs to be valid email addresses, to which a validation +email will be sent with an activation key. + +.. code-block:: ini + + kinto.account_validation = true + # Mail configuration: + # Set the sender for the validation email. + kinto.account_validation.email_sender = "admin@example.com" + +.. note:: + + Both the account validation and password reset need a properly configured + SMTP server. + To use a debug or testing mailer you may use the ``mail.mailer = debug`` or + ``mail.mailer = testing`` settings. Refer to + `pyramid_mailer's configuration `_. + +You can restrict the email addresses allowed using the +``account_validation.email_regexp`` setting, and the delay for which the +activation key will be valid: + +.. code-block:: ini + + # Set the regular expression used to validate a proper email address. + kinto.account_validation.email_regexp = "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$" + # Set the "time to live" for the activation key stored in the cache. After that + # delay the account won't be activable anymore. + kinto.account_validation.validation_key_cache_ttl_seconds = 604800 # 7 days in seconds. + +Once :ref:`created `, the account will need to be +:ref:`activated ` before being able to authenticate, using +the ``validate`` endpoint and the ``activation-key`` sent by email. + +If the user was created, an email was sent to the user with the activation key, +which needs to be POSTed to the ``validate`` endpoint. + +Example email: + +:: + + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: quoted-printable + From: admin@example.com + Subject: activate your account + To: bob@example.com + Content-Disposition: inline + + 2fe7a389-3556-4c8f-9513-c26bfc5f160b + +It is the responsability of the administrator to tell the mail recipient how to +validate the account by modifying the email body template in the settings. + +This could be done by providing a link to a webapp that displays a form to the +user with a call to action to validate the user, which will POST the activation +key to the ``validate`` endpoint. + +Or the email could explain how to copy the activation code and paste it in some +settings window. + +The templates for the email subject and body can be customized: + +.. code-block:: ini + + kinto.account_validation.email_subject_template = "Account activation" + kinto.account_validation.email_body_template = "Hello {id},\n you can now activate your account using the following link:\n {form-url}{activation-key}" + +... and they will be ``String.format``-ted with the content of the user, an +optional additional ``email-context`` provided alongside the user object, and +the ``activation-key`` (see the note in :ref:`account creation +` for an example usage). + +Whatever the means, a POST to the +``/accounts/(user_id)/validate/(activation_key)`` will validate and activate +the user. + +Once the account is validated, another email will be sent for confirmation, +rendered using the same ``email-context``. + +.. code-block:: ini + + kinto.account_validation.email_confirmation_subject_template = "Account active" + kinto.account_validation.email_confirmation_body_template = "Your account {id} is now active" + +.. _settings-account-password-reset: + +**About password reset** + +When the :ref:`account validation ` option is enabled, an +additional endpoint is available at ``/accounts/(user id)/reset-password`` to +require a temporary reset password by email (see :ref:`the API docs +`). + +Example email sent to the user with the temporary reset password: + +:: + + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: quoted-printable + From: admin@example.com + Subject: Reset password + To: mathieu@agopian.info + Content-Disposition: inline + + b8ae48e6-709e-4f01-bfb9-bca9464cdcfc + +The template used for the email subject and body can be customized using the +following settings: + +.. code-block:: ini + + kinto.account_validation.email_reset_password_subject_template = "Temporary reset password for {id}" + kinto.account_validation.email_reset_password_body_template = "Hello {id},\n you can use the following temporary reset password to change your password\n{reset-password}" + +Those templates will be rendered using the user record fields, an optional +additional ``email-context`` provided alongside the user object, and the +``reset-password`` (see the note in :ref:`resetting a forgotten password +` for an example usage). + + +It is the responsability of the administrator to tell the mail recipient how to +change their password using this temporary password. + +This could be done by providing a link to a webapp that displays a form to the +user asking for the new password and a call to action, which will POST the +new password to the ``accounts/(user_id)`` endpoint. + +Using this temporary reset password, one can +:ref:`update the account ` providing the new password. + +This temporary reset password will be valid for the amount of seconds set in +the settings: + +.. code-block:: ini + + # Set the "time to live" for the reset password stored in the cache. + kinto.account_validation.reset_password_cache_ttl_seconds = 604800 # 7 days in seconds. .. _settings-openid: diff --git a/kinto/__init__.py b/kinto/__init__.py index e49d6db84..0db6f2461 100644 --- a/kinto/__init__.py +++ b/kinto/__init__.py @@ -14,7 +14,7 @@ __version__ = pkg_resources.get_distribution(__package__).version # Implemented HTTP API Version -HTTP_API_VERSION = "1.21" +HTTP_API_VERSION = "1.22" # Main kinto logger logger = logging.getLogger(__name__) diff --git a/kinto/config/kinto.tpl b/kinto/config/kinto.tpl index a40e836bf..0cf2ec5a6 100644 --- a/kinto/config/kinto.tpl +++ b/kinto/config/kinto.tpl @@ -99,6 +99,18 @@ kinto.account_create_principals = system.Everyone kinto.account_write_principals = account:admin # Allow administrators to create buckets kinto.bucket_create_principals = account:admin +# Enable the "account_validation" option. +# kinto.account_validation = true +# Set the sender for the validation email. +# kinto.account_validation.email_sender = "admin@example.com" +# Set the regular expression used to validate a proper email address. +# kinto.account_validation.email_regexp = "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$" + +# Mail configuration (needed for the account validation option), see https://docs.pylonsproject.org/projects/pyramid_mailer/en/latest/#configuration +# mail.host = localhost +# mail.port = 25 +# mail.username = someusername +# mail.password = somepassword # Notifications # https://kinto.readthedocs.io/en/latest/configuration/settings.html#notifications diff --git a/kinto/plugins/accounts/__init__.py b/kinto/plugins/accounts/__init__.py index cf4fffc46..fc6320a40 100644 --- a/kinto/plugins/accounts/__init__.py +++ b/kinto/plugins/accounts/__init__.py @@ -4,22 +4,38 @@ from pyramid.exceptions import ConfigurationError from .authentication import AccountsAuthenticationPolicy as AccountsPolicy -from .utils import ACCOUNT_CACHE_KEY, ACCOUNT_POLICY_NAME +from .utils import ( + ACCOUNT_CACHE_KEY, + ACCOUNT_POLICY_NAME, + ACCOUNT_RESET_PASSWORD_CACHE_KEY, + ACCOUNT_VALIDATION_CACHE_KEY, +) -__all__ = ["ACCOUNT_CACHE_KEY", "ACCOUNT_POLICY_NAME", "AccountsPolicy"] +__all__ = [ + "ACCOUNT_CACHE_KEY", + "ACCOUNT_POLICY_NAME", + "ACCOUNT_RESET_PASSWORD_CACHE_KEY", + "ACCOUNT_VALIDATION_CACHE_KEY", + "AccountsPolicy", +] DOCS_URL = "https://kinto.readthedocs.io/en/stable/api/1.x/accounts.html" def includeme(config): + settings = config.get_settings() + validation_enabled = settings.get("account_validation", False) config.add_api_capability( "accounts", description="Manage user accounts.", url="https://kinto.readthedocs.io/en/latest/api/1.x/accounts.html", + validation_enabled=validation_enabled, ) - - config.scan("kinto.plugins.accounts.views") + kwargs = {} + if not validation_enabled: + kwargs["ignore"] = "kinto.plugins.accounts.views.validation" + config.scan("kinto.plugins.accounts.views", **kwargs) PERMISSIONS_INHERITANCE_TREE["root"].update({"account:create": {}}) PERMISSIONS_INHERITANCE_TREE["account"] = { @@ -27,7 +43,12 @@ def includeme(config): "read": {"account": ["write", "read"]}, } - settings = config.get_settings() + if validation_enabled: + # Valid mailers other than the default are `debug` and `testing` + # according to + # https://docs.pylonsproject.org/projects/pyramid_mailer/en/latest/#debugging + mailer = settings.get("mail.mailer", "") + config.include("pyramid_mailer" + (f".{mailer}" if mailer else "")) # Check that the account policy is mentioned in config if included. accountClass = "AccountsPolicy" diff --git a/kinto/plugins/accounts/authentication.py b/kinto/plugins/accounts/authentication.py index bbb6461b0..a1c16f36a 100644 --- a/kinto/plugins/accounts/authentication.py +++ b/kinto/plugins/accounts/authentication.py @@ -4,25 +4,32 @@ from kinto.core import utils from kinto.core.storage import exceptions as storage_exceptions -from .utils import ACCOUNT_CACHE_KEY, ACCOUNT_POLICY_NAME +from .utils import ( + cache_account, + get_account_cache_key, + get_cached_account, + get_cached_reset_password, + delete_cached_reset_password, + is_validated, + refresh_cached_account, + ACCOUNT_POLICY_NAME, +) def account_check(username, password, request): settings = request.registry.settings - hmac_secret = settings["userid_hmac_secret"] - cache_key = utils.hmac_digest(hmac_secret, ACCOUNT_CACHE_KEY.format(username)) - cache_ttl = int(settings.get("account_cache_ttl_seconds", 30)) + validation_enabled = settings.get("account_validation", False) + cache_key = get_account_cache_key(username, request.registry) hashed_password = utils.hmac_digest(cache_key, password) # Check cache to see whether somebody has recently logged in with the same # username and password. - cache = request.registry.cache - cache_result = cache.get(cache_key) + cache_result = get_cached_account(username, request.registry) # Username and password have been verified previously. No need to compare hashes if cache_result == hashed_password: # Refresh the cache TTL. - cache.expire(cache_key, cache_ttl) + refresh_cached_account(username, request.registry) return True # Back to standard procedure @@ -34,11 +41,53 @@ def account_check(username, password, request): except storage_exceptions.ObjectNotFoundError: return None + if validation_enabled and not is_validated(existing): + return None + hashed = existing["password"].encode(encoding="utf-8") pwd_str = password.encode(encoding="utf-8") # Check if password is valid (it is a very expensive computation) if bcrypt.checkpw(pwd_str, hashed): - cache.set(cache_key, hashed_password, ttl=cache_ttl) + cache_account(hashed_password, username, request.registry) + return True + + # Last chance, is this a "reset password" flow? + return reset_password_flow(username, password, request) + + +def reset_password_flow(username, password, request): + cache_key = get_account_cache_key(username, request.registry) + hashed_password = utils.hmac_digest(cache_key, password) + pwd_str = password.encode(encoding="utf-8") + + cached_password = get_cached_reset_password(username, request.registry) + if not cached_password: + return None + + # The temporary reset password is only available for changing a user's password. + if request.method.lower() not in ["post", "put", "patch"]: + return None + + # Only allow modifying a user account, no other resource. + uri = utils.strip_uri_prefix(request.path) + resource_name, _ = utils.view_lookup(request, uri) + if resource_name != "account": + return None + + try: + data = request.json["data"] + except (ValueError, KeyError): + return None + + # Request one and only one data field: the `password`. + if not data or "password" not in data or len(data.keys()) > 1: + return None + + cached_password_str = cached_password.encode(encoding="utf-8") + if bcrypt.checkpw(pwd_str, cached_password_str): + # Remove the temporary reset password from the cache. + delete_cached_reset_password(username, request.registry) + cache_account(hashed_password, username, request.registry) return True diff --git a/kinto/plugins/accounts/mails.py b/kinto/plugins/accounts/mails.py new file mode 100644 index 000000000..74a38c1e8 --- /dev/null +++ b/kinto/plugins/accounts/mails.py @@ -0,0 +1,95 @@ +import string +from pyramid_mailer import get_mailer +from pyramid_mailer.message import Message + + +DEFAULT_EMAIL_SENDER = "admin@example.com" +DEFAULT_SUBJECT_TEMPLATE = "activate your account" +DEFAULT_BODY_TEMPLATE = "{activation-key}" +DEFAULT_CONFIRMATION_SUBJECT_TEMPLATE = "Account active" +DEFAULT_CONFIRMATION_BODY_TEMPLATE = "The account {id} is now active" +DEFAULT_RESET_SUBJECT_TEMPLATE = "Reset password" +DEFAULT_RESET_BODY_TEMPLATE = "{reset-password}" + + +class EmailFormatter(string.Formatter): + """Formatter class that will not fail if there's a missing key.""" + + def __init__(self, default="{{{0}}}"): + self.default = default + + def get_value(self, key, args, kwargs): + return kwargs.get(key, self.default.format(key)) + + +class Emailer: + def __init__(self, request, user): + self.request = request + self.settings = request.registry.settings + self.user = user + self.user_email = user["id"] + self.email_sender = self.settings.get( + "account_validation.email_sender", DEFAULT_EMAIL_SENDER + ) + self.mailer = get_mailer(request) + + def send_mail(self, subject_template, body_template, extra_data=None): + formatter = EmailFormatter() + if extra_data is None: + extra_data = {} + user_email_context = self.user.get("email-context", {}) + # We might have some previous email context. + try: + data = self.request.json.get("data", {}) + email_context = data.get("email-context", user_email_context) + except ValueError: + email_context = user_email_context + + formatted_subject = formatter.format( + subject_template, **self.user, **extra_data, **email_context + ) + formatted_body = formatter.format( + body_template, **self.user, **extra_data, **email_context + ) + message = Message( + subject=formatted_subject, + sender=self.email_sender, + recipients=[self.user_email], + body=formatted_body, + ) + self.mailer.send(message) + + def send_activation(self, activation_key): + extra_data = {"activation-key": activation_key} + + subject_template = self.settings.get( + "account_validation.email_subject_template", DEFAULT_SUBJECT_TEMPLATE + ) + body_template = self.settings.get( + "account_validation.email_body_template", DEFAULT_BODY_TEMPLATE + ) + self.send_mail(subject_template, body_template, extra_data) + + def send_confirmation(self): + subject_template = self.settings.get( + "account_validation.email_confirmation_subject_template", + DEFAULT_CONFIRMATION_SUBJECT_TEMPLATE, + ) + body_template = self.settings.get( + "account_validation.email_confirmation_body_template", + DEFAULT_CONFIRMATION_BODY_TEMPLATE, + ) + + self.send_mail(subject_template, body_template) + + def send_temporary_reset_password(self, reset_password): + extra_data = {"reset-password": reset_password} + subject_template = self.settings.get( + "account_validation.email_reset_password_subject_template", + DEFAULT_RESET_SUBJECT_TEMPLATE, + ) + body_template = self.settings.get( + "account_validation.email_reset_password_body_template", DEFAULT_RESET_BODY_TEMPLATE + ) + + self.send_mail(subject_template, body_template, extra_data) diff --git a/kinto/plugins/accounts/utils.py b/kinto/plugins/accounts/utils.py index cb398dd97..f5e75c790 100644 --- a/kinto/plugins/accounts/utils.py +++ b/kinto/plugins/accounts/utils.py @@ -1,8 +1,14 @@ import bcrypt +from kinto.core import utils + ACCOUNT_CACHE_KEY = "accounts:{}:verified" ACCOUNT_POLICY_NAME = "account" +ACCOUNT_RESET_PASSWORD_CACHE_KEY = "accounts:{}:reset-password" +ACCOUNT_VALIDATION_CACHE_KEY = "accounts:{}:validation-key" +DEFAULT_RESET_PASSWORD_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60 +DEFAULT_VALIDATION_KEY_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60 def hash_password(password): @@ -11,3 +17,130 @@ def hash_password(password): pwd_str = password.encode(encoding="utf-8") hashed = bcrypt.hashpw(pwd_str, bcrypt.gensalt()) return hashed.decode(encoding="utf-8") + + +def is_validated(user): + """Is this user record validated?""" + # An account is "validated" if it has the `validated` field set to True, or + # no `validated` field at all (for accounts created before the "account + # validation option" was enabled). + return user.get("validated", True) + + +def get_account_cache_key(username, registry): + """Given a username, return the cache key for this account.""" + settings = registry.settings + hmac_secret = settings["userid_hmac_secret"] + cache_key = utils.hmac_digest(hmac_secret, ACCOUNT_CACHE_KEY.format(username)) + return cache_key + + +def cache_reset_password(reset_password, username, registry): + """Store a reset-password in the cache.""" + settings = registry.settings + hmac_secret = settings["userid_hmac_secret"] + cache_key = utils.hmac_digest(hmac_secret, ACCOUNT_RESET_PASSWORD_CACHE_KEY.format(username)) + # Store a reset password for 7 days by default. + cache_ttl = int( + settings.get( + "account_validation.reset_password_cache_ttl_seconds", + DEFAULT_RESET_PASSWORD_CACHE_TTL_SECONDS, + ) + ) + + cache = registry.cache + cache_result = cache.set(cache_key, reset_password, ttl=cache_ttl) + return cache_result + + +def get_cached_reset_password(username, registry): + """Given a username, get the reset-password from the cache.""" + hmac_secret = registry.settings["userid_hmac_secret"] + cache_key = utils.hmac_digest(hmac_secret, ACCOUNT_RESET_PASSWORD_CACHE_KEY.format(username)) + + cache = registry.cache + cache_result = cache.get(cache_key) + return cache_result + + +def delete_cached_reset_password(username, registry): + """Given a username, delete the reset-password from the cache.""" + hmac_secret = registry.settings["userid_hmac_secret"] + cache_key = utils.hmac_digest(hmac_secret, ACCOUNT_RESET_PASSWORD_CACHE_KEY.format(username)) + + cache = registry.cache + cache_result = cache.delete(cache_key) + return cache_result + + +def cache_validation_key(activation_key, username, registry): + """Store a validation_key in the cache.""" + settings = registry.settings + hmac_secret = settings["userid_hmac_secret"] + cache_key = utils.hmac_digest(hmac_secret, ACCOUNT_VALIDATION_CACHE_KEY.format(username)) + # Store an activation key for 7 days by default. + cache_ttl = int( + settings.get( + "account_validation.validation_key_cache_ttl_seconds", + DEFAULT_VALIDATION_KEY_CACHE_TTL_SECONDS, + ) + ) + + cache = registry.cache + cache_result = cache.set(cache_key, activation_key, ttl=cache_ttl) + return cache_result + + +def get_cached_validation_key(username, registry): + """Given a username, get the validation key from the cache.""" + hmac_secret = registry.settings["userid_hmac_secret"] + cache_key = utils.hmac_digest(hmac_secret, ACCOUNT_VALIDATION_CACHE_KEY.format(username)) + cache = registry.cache + activation_key = cache.get(cache_key) + return activation_key + + +def delete_cached_validation_key(username, registry): + """Given a username, delete the validation key from the cache.""" + hmac_secret = registry.settings["userid_hmac_secret"] + cache_key = utils.hmac_digest(hmac_secret, ACCOUNT_VALIDATION_CACHE_KEY.format(username)) + cache = registry.cache + cache_result = cache.delete(cache_key) + return cache_result + + +def cache_account(hashed_password, username, registry): + """Store an authenticated account in the cache.""" + settings = registry.settings + cache_ttl = int(settings.get("account_cache_ttl_seconds", 30)) + cache_key = get_account_cache_key(username, registry) + cache = registry.cache + cache_result = cache.set(cache_key, hashed_password, ttl=cache_ttl) + return cache_result + + +def get_cached_account(username, registry): + """Given a username, get the account from the cache.""" + cache_key = get_account_cache_key(username, registry) + cache = registry.cache + cached_account = cache.get(cache_key) + return cached_account + + +def refresh_cached_account(username, registry): + """Given a username, refresh the cache TTL.""" + settings = registry.settings + cache_ttl = int(settings.get("account_cache_ttl_seconds", 30)) + cache_key = get_account_cache_key(username, registry) + cache = registry.cache + cache_result = cache.expire(cache_key, cache_ttl) + return cache_result + + +def delete_cached_account(username, registry): + """Given a username, delete the account key from the cache.""" + hmac_secret = registry.settings["userid_hmac_secret"] + cache_key = utils.hmac_digest(hmac_secret, ACCOUNT_CACHE_KEY.format(username)) + cache = registry.cache + cache_result = cache.delete(cache_key) + return cache_result diff --git a/kinto/plugins/accounts/views.py b/kinto/plugins/accounts/views/__init__.py similarity index 64% rename from kinto/plugins/accounts/views.py rename to kinto/plugins/accounts/views/__init__.py index fb9bed9cb..3491381c0 100644 --- a/kinto/plugins/accounts/views.py +++ b/kinto/plugins/accounts/views/__init__.py @@ -1,16 +1,28 @@ import colander +import re +import uuid from pyramid import httpexceptions from pyramid.decorator import reify from pyramid.security import Authenticated, Everyone from pyramid.settings import aslist from pyramid.events import subscriber + from kinto.views import NameGenerator -from kinto.core import resource, utils +from kinto.core import resource from kinto.core.errors import raise_invalid, http_error from kinto.core.events import ResourceChanged, ACTIONS -from .utils import hash_password, ACCOUNT_CACHE_KEY, ACCOUNT_POLICY_NAME +from ..mails import Emailer +from ..utils import ( + cache_validation_key, + delete_cached_account, + get_cached_validation_key, + hash_password, + ACCOUNT_POLICY_NAME, +) + +DEFAULT_EMAIL_REGEXP = "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$" def _extract_posted_body_id(request): @@ -30,7 +42,7 @@ def _extract_posted_body_id(request): class AccountIdGenerator(NameGenerator): """Allow @ signs in account IDs.""" - regexp = r"^[a-zA-Z0-9][.@a-zA-Z0-9_-]*$" + regexp = r"^[a-zA-Z0-9][+.@a-zA-Z0-9_-]*$" class AccountSchema(resource.ResourceSchema): @@ -43,13 +55,21 @@ class Account(resource.Resource): schema = AccountSchema def __init__(self, request, context): + settings = request.registry.settings # Store if current user is administrator (before accessing get_parent_id()) - allowed_from_settings = request.registry.settings.get("account_write_principals", []) + allowed_from_settings = settings.get("account_write_principals", []) context.is_administrator = ( len(set(aslist(allowed_from_settings)) & set(request.prefixed_principals)) > 0 ) # Shortcut to check if current is anonymous (before get_parent_id()). context.is_anonymous = Authenticated not in request.effective_principals + # Is the "accounts validation" setting set? + context.validation_enabled = settings.get("account_validation", False) + # Account validation requires the user id to be an email. + validation_email_regexp = settings.get( + "account_validation.email_regexp", DEFAULT_EMAIL_REGEXP + ) + context.validation_email_regexp = re.compile(validation_email_regexp) super().__init__(request, context) @@ -103,16 +123,36 @@ def process_object(self, new, old=None): new["password"] = hash_password(new["password"]) - # Administrators can reach other accounts and anonymous have no - # selected_userid. So do not try to enforce. - if self.context.is_administrator or self.context.is_anonymous: - return new - # Do not let accounts be created without usernames. if self.model.id_field not in new: error_details = {"name": "data.id", "description": "Accounts must have an ID."} raise_invalid(self.request, **error_details) + # Account validation requires that the record ID is an email address. + # TODO: this might be better suited for a schema. Do we have a way to + # dynamically change the schema according to the settings? + if self.context.validation_enabled and old is None: + email_regexp = self.context.validation_email_regexp + # Account validation requires that the record ID is an email address. + user_email = new[self.model.id_field] + if not email_regexp.match(user_email): + error_details = { + "name": "data.id", + "description": f"Account validation is enabled, and user id should match {email_regexp}", + } + raise_invalid(self.request, **error_details) + + activation_key = str(uuid.uuid4()) + new["validated"] = False + + # Store the activation key in the cache to be used in the `validate` endpoint. + cache_validation_key(activation_key, new["id"], self.request.registry) + + # Administrators can reach other accounts and anonymous have no + # selected_userid. So do not try to enforce. + if self.context.is_administrator or self.context.is_anonymous: + return new + # Otherwise, we force the id to match the authenticated username. if new[self.model.id_field] != self.request.selected_userid: error_details = { @@ -130,13 +170,28 @@ def process_object(self, new, old=None): ) def on_account_changed(event): request = event.request - cache = request.registry.cache - settings = request.registry.settings - hmac_secret = settings["userid_hmac_secret"] for obj in event.impacted_objects: # Extract username and password from current user username = obj["old"]["id"] - cache_key = utils.hmac_digest(hmac_secret, ACCOUNT_CACHE_KEY.format(username)) # Delete cache - cache.delete(cache_key) + delete_cached_account(username, request.registry) + + +# Send activation code by email on account creation if account validation is enabled. +@subscriber(ResourceChanged, for_resources=("account",), for_actions=(ACTIONS.CREATE,)) +def on_account_created(event): + request = event.request + settings = request.registry.settings + if not settings.get("account_validation", False): + return + + for impacted_object in event.impacted_objects: + account = impacted_object["new"] + user_email = account["id"] + activation_key = get_cached_validation_key(user_email, request.registry) + if activation_key is None: + continue + + # Send an email to the user with the link to activate their account. + Emailer(request, account).send_activation(activation_key) diff --git a/kinto/plugins/accounts/views/validation.py b/kinto/plugins/accounts/views/validation.py new file mode 100644 index 000000000..71bfd0730 --- /dev/null +++ b/kinto/plugins/accounts/views/validation.py @@ -0,0 +1,136 @@ +import re +import uuid +from pyramid import httpexceptions +from pyramid.events import subscriber + + +from kinto.core import Service +from kinto.core.errors import raise_invalid, http_error +from kinto.core.events import ResourceChanged, ACTIONS +from kinto.core.storage import exceptions as storage_exceptions + +from ..mails import Emailer +from ..utils import ( + cache_reset_password, + delete_cached_validation_key, + get_cached_validation_key, + hash_password, +) + +from . import DEFAULT_EMAIL_REGEXP + +# Account validation (enable in the settings). +validation = Service( + name="account-validation", + path="/accounts/{user_id}/validate/{activation_key}", + description="Validate an account", +) + + +def check_validation_key(activation_key, username, registry): + """Given a username, compare the activation-key provided with the one from the cache.""" + cache_result = get_cached_validation_key(username, registry) + + if cache_result == activation_key: + delete_cached_validation_key(username, registry) # We're done with the activation key. + return True + return False + + +@validation.post() +def post_validation(request): + user_id = request.matchdict["user_id"] + activation_key = request.matchdict["activation_key"] + + parent_id = user_id + try: + user = request.registry.storage.get( + parent_id=parent_id, resource_name="account", object_id=user_id + ) + except storage_exceptions.ObjectNotFoundError: + # Don't give information on the existence of a user id: return a generic error message. + error_details = {"message": "Account ID and activation key do not match"} + raise http_error(httpexceptions.HTTPForbidden(), **error_details) + + if not check_validation_key(activation_key, user_id, request.registry): + error_details = {"message": "Account ID and activation key do not match"} + raise http_error(httpexceptions.HTTPForbidden(), **error_details) + + # User is now validated. + new = user.copy() + new["validated"] = True + + result = request.registry.storage.update( + parent_id=parent_id, resource_name="account", object_id=user_id, obj=new + ) + request.notify_resource_event( + parent_id=parent_id, + timestamp=result["last_modified"], + data=result, + action=ACTIONS.UPDATE, + old=user, + resource_name="account", + ) + return new + + +# Password reset. +reset_password = Service( + name="reset-password", + path="/accounts/{user_id}/reset-password", + description="Send a temporary reset password by mail for an account", +) + + +@reset_password.post() +def post_reset_password(request): + user_id = request.matchdict["user_id"] + + parent_id = user_id + try: + user = request.registry.storage.get( + parent_id=parent_id, resource_name="account", object_id=user_id + ) + except storage_exceptions.ObjectNotFoundError: + # Don't give information on the existence of a user id: return a generic message. + return {"message": "A temporary reset password has been sent by mail"} + + settings = request.registry.settings + + user_email = user["id"] + email_regexp = settings.get("account_validation.email_regexp", DEFAULT_EMAIL_REGEXP) + compiled_email_regexp = re.compile(email_regexp) + if not compiled_email_regexp.match(user_email): + error_details = { + "name": "data.id", + "description": f"The user id should match {email_regexp}.", + } + raise_invalid(request, **error_details) + + reset_password = str(uuid.uuid4()) + hashed_reset_password = hash_password(reset_password) + cache_reset_password(hashed_reset_password, user_id, request.registry) + + # Send a temporary reset password by mail. + Emailer(request, user).send_temporary_reset_password(reset_password) + + return {"message": "A temporary reset password has been sent by mail"} + + +# Send confirmation email on account activation if account validation is enabled. +@subscriber(ResourceChanged, for_resources=("account",), for_actions=(ACTIONS.UPDATE,)) +def on_account_activated(event): + request = event.request + settings = request.registry.settings + if not settings.get("account_validation", False): + return + + for impacted_object in event.impacted_objects: + old_account = impacted_object["old"] + account = impacted_object["new"] + if old_account.get("validated", True) or not account.get("validated", False): + # It's not an account activation, bail. + continue + + # Send a confirmation email. + Emailer(request, account).send_confirmation() diff --git a/setup.cfg b/setup.cfg index 9a62cb1c5..ac3c1f0bb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,6 +34,7 @@ install_requires = logging-color-formatter python-dateutil pyramid<2.0 + pyramid_mailer pyramid_multiauth transaction pyramid_tm diff --git a/tests/plugins/test_accounts.py b/tests/plugins/test_accounts.py index 9ba670071..5856fe2ff 100644 --- a/tests/plugins/test_accounts.py +++ b/tests/plugins/test_accounts.py @@ -1,11 +1,22 @@ +import bcrypt import unittest +import uuid from unittest import mock from kinto.core import utils -from kinto.core.testing import get_user_headers +from kinto.core.events import ACTIONS, ResourceChanged +from kinto.core.testing import get_user_headers, DummyRequest from pyramid.exceptions import ConfigurationError +from pyramid_mailer import get_mailer from kinto.plugins.accounts import scripts, ACCOUNT_CACHE_KEY +from kinto.plugins.accounts.utils import ( + get_cached_reset_password, + get_cached_validation_key, + hash_password, +) +from kinto.plugins.accounts.views import on_account_created +from kinto.plugins.accounts.views.validation import on_account_activated from .. import support @@ -26,6 +37,48 @@ def get_app_settings(cls, extras=None): return super().get_app_settings(extras) +class AccountsValidationWebTest(AccountsWebTest): + def setUp(self): + self.mailer = get_mailer(self.app.app.registry) + self.mailer.outbox = [] # Reset the outbox before each test. + + @classmethod + def get_app_settings(cls, extras=None): + if extras is None: + extras = {} + # Enable the account validation option. + extras.setdefault("account_validation", True) + # Use a testing mailer. + extras.setdefault("mail.mailer", "testing") + # Email templates for the user creation. + extras.setdefault( + "account_validation.email_subject_template", "{name}, activate your account {id}" + ) + extras.setdefault( + "account_validation.email_body_template", + "{activation-form-url}/{id}/{activation-key} {bad-key}", + ) + # Email templates for the user validated confirmation. + extras.setdefault( + "account_validation.email_confirmation_subject_template", + "{name}, your account {id} is now active", + ) + extras.setdefault( + "account_validation.email_confirmation_body_template", + "Your account {id} has been successfully activated. Connect to {homepage}", + ) + # Email templates for the reset password. + extras.setdefault( + "account_validation.email_reset_password_subject_template", + "{name}, here is a temporary reset password for {id}", + ) + extras.setdefault( + "account_validation.email_reset_password_body_template", + "You can use this temporary reset password {reset-password} to change your account {id} password", + ) + return super().get_app_settings(extras) + + class BadAccountsConfigTest(support.BaseWebTest, unittest.TestCase): def test_raise_configuration_if_accounts_not_mentioned(self): with self.assertRaises(ConfigurationError) as cm: @@ -42,6 +95,15 @@ def test_accounts_capability_if_enabled(self): self.assertIn("accounts", capabilities) +class HelloActivationViewTest(AccountsValidationWebTest): + def test_account_validation_capability_if_enabled(self): + resp = self.app.get("/") + capabilities = resp.json["capabilities"] + self.assertIn("accounts", capabilities) + self.assertIn("validation_enabled", capabilities["accounts"]) + self.assertTrue(capabilities["accounts"]["validation_enabled"]) + + class AccountCreationTest(AccountsWebTest): def test_anyone_can_create_an_account(self): self.app.post_json("/accounts", {"data": {"id": "alice", "password": "12éé6"}}, status=201) @@ -69,7 +131,7 @@ def test_id_field_is_mandatory(self): def test_id_can_be_email(self): self.app.put_json( - "/accounts/alice@example.com", {"data": {"password": "123456"}}, status=201 + "/accounts/alice+test@example.com", {"data": {"password": "123456"}}, status=201 ) def test_account_can_have_metadata(self): @@ -157,6 +219,332 @@ def test_authentication_refresh_the_cache_each_time_we_authenticate(self): resp = self.app.get("/", headers=get_user_headers("me", "blah")) assert "user" not in resp.json + def test_validate_view_not_active(self): + # The `validate` view is only active when the `account_validation` option is enabled. + # Create the user. + self.app.post_json( + "/accounts", {"data": {"id": "alice@example.com", "password": "12éé6"}}, status=201 + ) + # Validate the user. + self.app.post_json("/accounts/alice@example.com/validate/some_validation_key", status=404) + + def test_reset_password_view_not_active(self): + # The `validate` view is only active when the `account_validation` option is enabled. + # Create the user. + self.app.post_json( + "/accounts", {"data": {"id": "alice@example.com", "password": "12éé6"}}, status=201 + ) + # Ask for a reset password. + self.app.post_json("/accounts/alice@example.com/reset-password", status=404) + + +class AccountValidationCreationTest(AccountsValidationWebTest): + def test_create_account_fails_if_not_email(self): + resp = self.app.post_json( + "/accounts", {"data": {"id": "alice", "password": "12éé6"}}, status=400 + ) + assert "user id should match" in resp.json["message"] + + def test_create_account_stores_activated_field(self): + uuid_string = "20e81ab7-51c0-444f-b204-f1c4cfe1aa7a" + with mock.patch("uuid.uuid4", return_value=uuid.UUID(uuid_string)): + resp = self.app.post_json( + "/accounts", + { + "data": { + "id": "alice@example.com", + "password": "12éé6", + "email-context": { + "name": "Alice", + "activation-form-url": "https://example.com", + }, + } + }, + status=201, + ) + assert "activation-key" not in resp.json["data"] + assert "validated" in resp.json["data"] + assert not resp.json["data"]["validated"] + assert len(self.mailer.outbox) == 1 + mail = self.mailer.outbox[0] # Get the validation email. + assert mail.sender == "admin@example.com" + assert mail.subject == "Alice, activate your account alice@example.com" + assert mail.recipients == ["alice@example.com"] + # The {{bad-key}} from the template will be rendered as {bad-key} in + # the final email, instead of failing the formatting. + assert mail.body == f"https://example.com/alice@example.com/{uuid_string} {{bad-key}}" + # The activation key is stored in the cache. + assert get_cached_validation_key("alice@example.com", self.app.app.registry) == uuid_string + + def test_cant_authenticate_with_unactivated_account(self): + self.app.post_json( + "/accounts", + {"data": {"id": "alice@example.com", "password": "12éé6", "activated": False}}, + status=201, + ) + resp = self.app.get("/", headers=get_user_headers("alice@example.com", "12éé6")) + assert "user" not in resp.json + + def test_validation_fail_bad_user(self): + # Validation should fail on a non existing user. + resp = self.app.post_json("/accounts/alice@example.com/validate/123", {}, status=403) + assert "Account ID and activation key do not match" in resp.json["message"] + + def test_validation_fail_bad_activation_key(self): + uuid_string = "20e81ab7-51c0-444f-b204-f1c4cfe1aa7a" + with mock.patch("uuid.uuid4", return_value=uuid.UUID(uuid_string)): + self.app.post_json( + "/accounts", {"data": {"id": "alice@example.com", "password": "12éé6"}}, status=201 + ) + # Validate the user. + resp = self.app.post_json( + "/accounts/alice@example.com/validate/bad-activation-key", {}, status=403 + ) + assert "Account ID and activation key do not match" in resp.json["message"] + # The activation key is still in the cache + assert get_cached_validation_key("alice@example.com", self.app.app.registry) is not None + + def test_validation_validates_user(self): + # On user activation the 'validated' field is set to True. + uuid_string = "20e81ab7-51c0-444f-b204-f1c4cfe1aa7a" + with mock.patch("uuid.uuid4", return_value=uuid.UUID(uuid_string)): + self.app.post_json( + "/accounts", + { + "data": { + "id": "alice@example.com", + "password": "12éé6", + "email-context": {"name": "Alice", "homepage": "https://example.com"}, + } + }, + status=201, + ) + resp = self.app.post_json( + "/accounts/alice@example.com/validate/" + uuid_string, {}, status=200 + ) + assert "validated" in resp.json + assert resp.json["validated"] + # An active user can authenticate. + resp = self.app.get("/", headers=get_user_headers("alice@example.com", "12éé6")) + assert resp.json["user"]["id"] == "account:alice@example.com" + # Once activated, the activation key is removed from the cache. + assert get_cached_validation_key("alice@example.com", self.app.app.registry) is None + assert len(self.mailer.outbox) == 2 # Validation email, reset password email. + mail = self.mailer.outbox[1] # Get the confirmation email. + assert mail.sender == "admin@example.com" + assert mail.subject == "Alice, your account alice@example.com is now active" + assert mail.recipients == ["alice@example.com"] + assert ( + mail.body + == "Your account alice@example.com has been successfully activated. Connect to https://example.com" + ) + + def test_previously_created_accounts_can_still_authenticate(self): + """Accounts created before activating the 'account validation' option can still authenticate.""" + # Create an account without going through the accounts API. + hashed_password = hash_password("12éé6") + self.app.app.registry.storage.create( + parent_id="alice", + resource_name="account", + record={"id": "alice", "password": hashed_password}, + ) + resp = self.app.get("/", headers=get_user_headers("alice", "12éé6")) + assert resp.json["user"]["id"] == "account:alice" + + def test_reset_password_bad_user(self): + resp = self.app.post_json("/accounts/alice@example.com/reset-password", {}, status=200) + # Don't give information on the existence of a user id: return a generic message. + assert resp.json["message"] == "A temporary reset password has been sent by mail" + # Make sure no email was sent. + assert len(self.mailer.outbox) == 0 + + def test_reset_password_bad_email(self): + # Create an account without going through the accounts API. + hashed_password = hash_password("12éé6") + self.app.app.registry.storage.create( + parent_id="alice", + resource_name="account", + record={"id": "alice", "password": hashed_password}, + ) + resp = self.app.post_json("/accounts/alice/reset-password", {}, status=400) + assert "user id should match" in resp.json["message"] + + def test_reset_password_sends_email(self): + reset_password = "20e81ab7-51c0-444f-b204-f1c4cfe1aa7a" + with mock.patch("uuid.uuid4", return_value=uuid.UUID(reset_password)): + # Create the user. + self.app.post_json( + "/accounts", {"data": {"id": "alice@example.com", "password": "12éé6"}}, status=201 + ) + # Ask for a reset password. + resp = self.app.post_json( + "/accounts/alice@example.com/reset-password", + {"data": {"email-context": {"name": "Alice"}}}, + status=200, + ) + assert resp.json["message"] == "A temporary reset password has been sent by mail" + assert len(self.mailer.outbox) == 2 # Validation email, reset password email. + mail = self.mailer.outbox[1] # Get the reset password email + assert mail.sender == "admin@example.com" + assert mail.subject == "Alice, here is a temporary reset password for alice@example.com" + assert ( + mail.body + == f"You can use this temporary reset password {reset_password} to change your account alice@example.com password" + ) + # The reset password is stored in the cache. + cached_password = get_cached_reset_password( + "alice@example.com", self.app.app.registry + ).encode(encoding="utf-8") + pwd_str = reset_password.encode(encoding="utf-8") + assert bcrypt.checkpw(pwd_str, cached_password) + + def test_fail_use_reset_password_bad_data(self): + validation_key = reset_password = "20e81ab7-51c0-444f-b204-f1c4cfe1aa7a" + with mock.patch("uuid.uuid4", return_value=uuid.UUID(reset_password)): + # Create the user. + self.app.post_json( + "/accounts", {"data": {"id": "alice@example.com", "password": "12éé6"}}, status=201 + ) + # Validate the user. + resp = self.app.post_json( + "/accounts/alice@example.com/validate/" + validation_key, status=200 + ) + # Ask for a reset password. + self.app.post_json("/accounts/alice@example.com/reset-password", status=200) + # Using reset password needs data. + self.app.put_json( + "/accounts/alice@example.com", + headers=get_user_headers("alice@example.com", reset_password), + status=401, + ) + # Using reset password needs password field. + self.app.put_json( + "/accounts/alice@example.com", + {"data": {"foo": "bar"}}, + headers=get_user_headers("alice@example.com", reset_password), + status=401, + ) + # Using reset password accepts only password field. + self.app.put_json( + "/accounts/alice@example.com", + {"data": {"password": "newpass", "foo": "bar"}}, + headers=get_user_headers("alice@example.com", reset_password), + status=401, + ) + # Using wrong reset password fails. + self.app.put_json( + "/accounts/alice@example.com", + {"data": {"password": "newpass"}}, + headers=get_user_headers("alice@example.com", "some random password"), + status=401, + ) + # Can't use the reset password to modify other resources than accounts. + resp = self.app.post_json( + "/buckets/default/collections", + {"data": {"id": "some_collection_id"}}, + headers=get_user_headers("alice@example.com", reset_password), + status=401, + ) + assert resp.json["message"] == "Please authenticate yourself to use this endpoint." + # Can't use reset password to authenticate. + resp = self.app.get("/", headers=get_user_headers("alice@example.com", reset_password)) + assert "user" not in resp.json + + def test_use_reset_password_to_change_password(self): + validation_key = reset_password = "20e81ab7-51c0-444f-b204-f1c4cfe1aa7a" + with mock.patch("uuid.uuid4", return_value=uuid.UUID(reset_password)): + # Create the user. + self.app.post_json( + "/accounts", {"data": {"id": "alice@example.com", "password": "12éé6"}}, status=201 + ) + # Validate the user. + resp = self.app.post_json( + "/accounts/alice@example.com/validate/" + validation_key, {}, status=200 + ) + # Ask for a reset password. + self.app.post_json("/accounts/alice@example.com/reset-password", {}, status=200) + # Use reset password to set a new password. + self.app.patch_json( + "/accounts/alice@example.com", + {"data": {"password": "newpass"}}, + headers=get_user_headers("alice@example.com", reset_password), + status=200, + ) + # Can use the new password to authenticate. + resp = self.app.get("/", headers=get_user_headers("alice@example.com", "newpass")) + assert resp.json["user"]["id"] == "account:alice@example.com" + # The user hasn't changed. + resp = self.app.get( + "/accounts/alice@example.com", headers=get_user_headers("alice@example.com", "newpass") + ) + assert resp.json["data"]["id"] == "alice@example.com" + assert resp.json["data"]["validated"] + # The reset password isn't in the cache anymore + assert get_cached_reset_password("alice@example.com", self.app.app.registry) is None + # Can't use the reset password anymore to authenticate. + resp = self.app.get("/", headers=get_user_headers("alice@example.com", reset_password)) + assert "user" not in resp.json + + def test_user_creation_listener(self): + request = DummyRequest() + impacted_object = {"new": {"id": "alice", "password": "12éé6"}} + with mock.patch("kinto.plugins.accounts.mails.Emailer.send_mail") as mocked_send_mail: + # No email sent if account validation is not enabled. + event = ResourceChanged({"action": ACTIONS.UPDATE.value}, [impacted_object], request) + on_account_created(event) + mocked_send_mail.assert_not_called() + # No email sent if there's no activation key in the cache. + request.registry.settings["account_validation"] = True + event = ResourceChanged({"action": ACTIONS.UPDATE.value}, [impacted_object], request) + request.registry.cache.get = mock.MagicMock(return_value=None) + on_account_created(event) + mocked_send_mail.assert_not_called() + # Email sent if there is an activation key in the cache. + request.registry.cache.get = mock.MagicMock(return_value="some activation key") + on_account_created(event) + mocked_send_mail.assert_called_once() + + def test_user_validation_listener(self): + request = DummyRequest() + old_inactive = {"id": "alice", "password": "12éé6", "validated": False} + old_active = {"id": "alice", "password": "12éé6", "validated": True} + new_inactive = {"id": "alice", "password": "12éé6", "validated": False} + new_active = {"id": "alice", "password": "12éé6", "validated": True} + with mock.patch("kinto.plugins.accounts.mails.Emailer.send_mail") as mocked_send_mail: + # No email sent if account validation is not enabled. + event = ResourceChanged( + {"action": ACTIONS.UPDATE.value}, + [{"old": old_inactive, "new": new_inactive}], + request, + ) + on_account_activated(event) + mocked_send_mail.assert_not_called() + # No email sent if the old account was already active. + request.registry.settings["account_validation"] = True + event = ResourceChanged( + {"action": ACTIONS.UPDATE.value}, [{"old": old_active, "new": new_active}], request + ) + request.registry.cache.get = mock.MagicMock(return_value=None) + on_account_activated(event) + mocked_send_mail.assert_not_called() + # No email sent if the new account is still inactive. + event = ResourceChanged( + {"action": ACTIONS.UPDATE.value}, + [{"old": old_inactive, "new": new_inactive}], + request, + ) + request.registry.cache.get = mock.MagicMock(return_value=None) + on_account_activated(event) + mocked_send_mail.assert_not_called() + # Email sent if there is an activation key in the cache. + event = ResourceChanged( + {"action": ACTIONS.UPDATE.value}, + [{"old": old_inactive, "new": new_active}], + request, + ) + on_account_activated(event) + mocked_send_mail.assert_called_once() + class AccountUpdateTest(AccountsWebTest): def setUp(self): diff --git a/tests/test_config.py b/tests/test_config.py index 8d945210a..d07730991 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -66,6 +66,7 @@ def test_hmac_secret_is_text(self, mocked_render_template): @mock.patch("kinto.config.render_template") def test_init_postgresql_values(self, mocked_render_template): + self.maxDiff = None config.init("kinto.ini", backend="postgresql", cache_backend="postgresql") args, kwargs = list(mocked_render_template.call_args) diff --git a/tests/test_configuration/test.ini b/tests/test_configuration/test.ini index f1a44ca6a..d5c334da2 100644 --- a/tests/test_configuration/test.ini +++ b/tests/test_configuration/test.ini @@ -99,6 +99,18 @@ kinto.account_create_principals = system.Everyone kinto.account_write_principals = account:admin # Allow administrators to create buckets kinto.bucket_create_principals = account:admin +# Enable the "account_validation" option. +# kinto.account_validation = true +# Set the sender for the validation email. +# kinto.account_validation.email_sender = "admin@example.com" +# Set the regular expression used to validate a proper email address. +# kinto.account_validation.email_regexp = "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$" + +# Mail configuration (needed for the account validation option), see https://docs.pylonsproject.org/projects/pyramid_mailer/en/latest/#configuration +# mail.host = localhost +# mail.port = 25 +# mail.username = someusername +# mail.password = somepassword # Notifications # https://kinto.readthedocs.io/en/latest/configuration/settings.html#notifications