diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 04ec7eb609..463b57e4a4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -27,6 +27,11 @@ This document describes changes between each past release. - 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`_. diff --git a/docs/api/1.x/accounts.rst b/docs/api/1.x/accounts.rst index 1d9e2eb2ca..05b7265a6c 100644 --- a/docs/api/1.x/accounts.rst +++ b/docs/api/1.x/accounts.rst @@ -153,6 +153,15 @@ Alternatively, accounts can be created using POST. Supply the user id and passw 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-update: @@ -361,12 +370,12 @@ 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`. +be requested through the endpoint available at ``/accounts/(user +id)/reset-password``. .. http:post:: /accounts/(user_id)/reset-password - :synopsis: Require a temporary reset password for an account with the ``account_validation`` option enabled. + :synopsis: Request a temporary reset password for an account with the ``account_validation`` option enabled. **Anonymous** @@ -404,5 +413,15 @@ id)/reset-password`. "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 4bafd8225c..be248c7b48 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -14,11 +14,13 @@ Changelog 1.22 (unreleased) ''''''''''''''''' -- new `/accounts/{user id}/validate/{validation key}` endpoint 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 (#1973) +- 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 75bbafbeb4..cb6896a1c2 100644 --- a/docs/configuration/settings.rst +++ b/docs/configuration/settings.rst @@ -587,7 +587,7 @@ email will be sent with an activation key. .. note:: Both the account validation and password reset need a properly configured - smtp server. + 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 `_. @@ -604,13 +604,9 @@ activation key will be valid: # 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 activated -before being able to authenticate, using the ``validate`` endpoint and the -``activation-key`` sent by email. - -.. sourcecode:: bash - - $ echo '{"data": {"id": "bob@example.com", "password": "azerty123"}}' | http POST http://localhost:8888/v1/accounts --verbose +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. @@ -648,11 +644,8 @@ The templates for the email subject and body can be customized: ... 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``: - -.. 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 +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 @@ -666,17 +659,16 @@ rendered using the same ``email-context``. 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. - -.. sourcecode:: bash +require a temporary reset password by email (see :ref:`the API docs +`). - $ http POST http://localhost:8888/v1/accounts/bob@example.com/reset-password --verbose - -Example email: +Example email sent to the user with the temporary reset password: :: @@ -700,7 +692,8 @@ following settings: Those templates will be rendered using the user record fields, an optional additional ``email-context`` provided alongside the user object, and the -``reset-password``. +``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 @@ -710,11 +703,6 @@ 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. - -.. 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/kinto/plugins/accounts/authentication.py b/kinto/plugins/accounts/authentication.py index cbf55078c6..bba252e651 100644 --- a/kinto/plugins/accounts/authentication.py +++ b/kinto/plugins/accounts/authentication.py @@ -7,9 +7,9 @@ from .utils import ( ACCOUNT_CACHE_KEY, ACCOUNT_POLICY_NAME, - ACCOUNT_RESET_PASSWORD_CACHE_KEY, is_validated, get_cached_reset_password, + delete_cached_reset_password, ) @@ -52,7 +52,7 @@ def account_check(username, password, request): return True # Last chance, is this a "reset password" flow? - reset_password_flow(username, password, request) + return reset_password_flow(username, password, request) def reset_password_flow(username, password, request): @@ -80,7 +80,7 @@ def reset_password_flow(username, password, request): try: data = request.json["data"] - except ValueError: + except (ValueError, KeyError): return None # Request one and only one data field: the `password`. @@ -90,10 +90,7 @@ def reset_password_flow(username, password, request): 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. - reset_password_cache_key = utils.hmac_digest( - hmac_secret, ACCOUNT_RESET_PASSWORD_CACHE_KEY.format(username) - ) - cache.delete(reset_password_cache_key) + delete_cached_reset_password(username, request.registry) cache.set(cache_key, hashed_password, ttl=cache_ttl) return True diff --git a/kinto/plugins/accounts/utils.py b/kinto/plugins/accounts/utils.py index f45411d406..b35e5e5075 100644 --- a/kinto/plugins/accounts/utils.py +++ b/kinto/plugins/accounts/utils.py @@ -7,6 +7,8 @@ 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): @@ -25,6 +27,23 @@ def is_validated(user): return user.get("validated", True) +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.set(cache_key, reset_password, ttl=cache_ttl) + + def get_cached_reset_password(username, registry): """Given a username, get the reset-password from the cache.""" hmac_secret = registry.settings["userid_hmac_secret"] @@ -33,3 +52,57 @@ def get_cached_reset_password(username, registry): 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.set(cache_key, activation_key, ttl=cache_ttl) + + +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 delete_cached_account_key(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/__init__.py b/kinto/plugins/accounts/views/__init__.py index fdd4d48a01..a8ec6a8dd5 100644 --- a/kinto/plugins/accounts/views/__init__.py +++ b/kinto/plugins/accounts/views/__init__.py @@ -9,20 +9,20 @@ 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 ..mails import Emailer from ..utils import ( + cache_validation_key, + delete_cached_account_key, + get_cached_validation_key, hash_password, - ACCOUNT_CACHE_KEY, ACCOUNT_POLICY_NAME, - ACCOUNT_VALIDATION_CACHE_KEY, ) DEFAULT_EMAIL_REGEXP = "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$" -DEFAULT_VALIDATION_KEY_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60 def _extract_posted_body_id(request): @@ -39,23 +39,6 @@ def _extract_posted_body_id(request): raise http_error(httpexceptions.HTTPUnauthorized(), error=error_msg) -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.set(cache_key, activation_key, ttl=cache_ttl) - - class AccountIdGenerator(NameGenerator): """Allow @ signs in account IDs.""" @@ -187,16 +170,12 @@ 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_key(username, request.registry) # Send activation code by email on account creation if account validation is enabled. @@ -207,14 +186,10 @@ def on_account_created(event): if not settings.get("account_validation", False): return - cache = request.registry.cache - hmac_secret = settings["userid_hmac_secret"] - for impacted_object in event.impacted_objects: account = impacted_object["new"] user_email = account["id"] - cache_key = utils.hmac_digest(hmac_secret, ACCOUNT_VALIDATION_CACHE_KEY.format(user_email)) - activation_key = cache.get(cache_key) + activation_key = get_cached_validation_key(user_email, request.registry) if activation_key is None: continue diff --git a/kinto/plugins/accounts/views/validation.py b/kinto/plugins/accounts/views/validation.py index 57e7f7e262..71bfd0730b 100644 --- a/kinto/plugins/accounts/views/validation.py +++ b/kinto/plugins/accounts/views/validation.py @@ -4,18 +4,21 @@ from pyramid.events import subscriber -from kinto.core import Service, utils +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 hash_password, ACCOUNT_RESET_PASSWORD_CACHE_KEY, ACCOUNT_VALIDATION_CACHE_KEY +from ..utils import ( + cache_reset_password, + delete_cached_validation_key, + get_cached_validation_key, + hash_password, +) from . import DEFAULT_EMAIL_REGEXP -DEFAULT_RESET_PASSWORD_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60 - # Account validation (enable in the settings). validation = Service( name="account-validation", @@ -26,14 +29,10 @@ def check_validation_key(activation_key, username, registry): """Given a username, compare the activation-key provided with the one 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.get(cache_key) + cache_result = get_cached_validation_key(username, registry) if cache_result == activation_key: - cache.delete(cache_key) # We're done with the activation key. + delete_cached_validation_key(username, registry) # We're done with the activation key. return True return False @@ -83,23 +82,6 @@ def post_validation(request): ) -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.set(cache_key, reset_password, ttl=cache_ttl) - - @reset_password.post() def post_reset_password(request): user_id = request.matchdict["user_id"] diff --git a/tests/plugins/test_accounts.py b/tests/plugins/test_accounts.py index cbb9544fcf..8c742c9fa4 100644 --- a/tests/plugins/test_accounts.py +++ b/tests/plugins/test_accounts.py @@ -12,10 +12,12 @@ from kinto.plugins.accounts import ( scripts, ACCOUNT_CACHE_KEY, - ACCOUNT_RESET_PASSWORD_CACHE_KEY, - ACCOUNT_VALIDATION_CACHE_KEY, ) -from kinto.plugins.accounts.utils import hash_password +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 @@ -240,18 +242,6 @@ def test_reset_password_view_not_active(self): class AccountValidationCreationTest(AccountsValidationWebTest): - def get_account_validation_cache(self, username): - hmac_secret = self.app.app.registry.settings["userid_hmac_secret"] - cache_key = utils.hmac_digest(hmac_secret, ACCOUNT_VALIDATION_CACHE_KEY.format(username)) - return self.app.app.registry.cache.get(cache_key) - - def get_account_reset_password_cache(self, username): - hmac_secret = self.app.app.registry.settings["userid_hmac_secret"] - cache_key = utils.hmac_digest( - hmac_secret, ACCOUNT_RESET_PASSWORD_CACHE_KEY.format(username) - ) - return self.app.app.registry.cache.get(cache_key) - def test_create_account_fails_if_not_email(self): resp = self.app.post_json( "/accounts", {"data": {"id": "alice", "password": "12éé6"}}, status=400 @@ -287,7 +277,7 @@ def test_create_account_stores_activated_field(self): # 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 self.get_account_validation_cache("alice@example.com") == uuid_string + 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( @@ -315,7 +305,7 @@ def test_validation_fail_bad_activation_key(self): ) assert "Account ID and activation key do not match" in resp.json["message"] # The activation key is still in the cache - assert self.get_account_validation_cache("alice@example.com") is not None + 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. @@ -341,7 +331,7 @@ def test_validation_validates_user(self): 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 self.get_account_validation_cache("alice@example.com") is None + 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" @@ -405,7 +395,7 @@ def test_reset_password_sends_email(self): == 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 = self.get_account_reset_password_cache("alice@example.com").encode( + 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") @@ -493,7 +483,7 @@ def test_use_reset_password_to_change_password(self): assert resp.json["data"]["id"] == "alice@example.com" assert resp.json["data"]["validated"] # The reset password isn't in the cache anymore - assert self.get_account_reset_password_cache("alice@example.com") is None + 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