diff --git a/kinto/plugins/accounts/__init__.py b/kinto/plugins/accounts/__init__.py index 58c7549f62..feb503114a 100644 --- a/kinto/plugins/accounts/__init__.py +++ b/kinto/plugins/accounts/__init__.py @@ -5,7 +5,7 @@ from pyramid.settings import asbool 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_VALIDATION_CACHE_KEY __all__ = ["ACCOUNT_CACHE_KEY", "ACCOUNT_POLICY_NAME", "AccountsPolicy"] diff --git a/kinto/plugins/accounts/utils.py b/kinto/plugins/accounts/utils.py index dd02b7fab7..c61fcd82db 100644 --- a/kinto/plugins/accounts/utils.py +++ b/kinto/plugins/accounts/utils.py @@ -3,6 +3,7 @@ ACCOUNT_CACHE_KEY = "accounts:{}:verified" ACCOUNT_POLICY_NAME = "account" +ACCOUNT_VALIDATION_CACHE_KEY = "accounts:{}:validation-key" def hash_password(password): diff --git a/kinto/plugins/accounts/views.py b/kinto/plugins/accounts/views.py index d92ca0a5e5..41fa27c674 100644 --- a/kinto/plugins/accounts/views.py +++ b/kinto/plugins/accounts/views.py @@ -16,7 +16,13 @@ from kinto.core.events import ResourceChanged, ACTIONS from kinto.core.storage import exceptions as storage_exceptions -from .utils import hash_password, is_validated, ACCOUNT_CACHE_KEY, ACCOUNT_POLICY_NAME +from .utils import ( + hash_password, + is_validated, + ACCOUNT_CACHE_KEY, + ACCOUNT_POLICY_NAME, + ACCOUNT_VALIDATION_CACHE_KEY, +) def _extract_posted_body_id(request): @@ -159,15 +165,18 @@ def process_object(self, new, old=None): raise_invalid(self.request, **error_details) activation_key = str(uuid.uuid4()) - new["activation-key"] = activation_key + extra_data = {"activation-key": activation_key} 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) + # Send an email to the user with the link to activate their account. message = Message( - subject=self.email_subject_template.format(**new), + subject=self.email_subject_template.format(**extra_data, **new), sender=self.email_sender, recipients=[user_email], - body=self.email_body_template.format(**new), + body=self.email_body_template.format(**extra_data, **new), ) self.mailer.send(message) @@ -211,6 +220,32 @@ def on_account_changed(event): ) +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.cache_ttl_seconds", 7 * 24 * 60)) + + cache = registry.cache + cache.set(cache_key, activation_key, ttl=cache_ttl) + + +def check_validation_key(activation_key, username, registry): + """Given a username, get the activation-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.get(cache_key) + + if cache_result == activation_key: + cache.delete(cache_key) # We're done with the activation key. + return True + return False + + @validation.post() def post_validation(request): user_id = request.matchdict["user_id"] @@ -230,7 +265,7 @@ def post_validation(request): error_details = {"message": f"Account {user_id} has already been validated"} raise http_error(httpexceptions.HTTPForbidden(), **error_details) - if user["activation-key"] != activation_key: + 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) diff --git a/tests/plugins/test_accounts.py b/tests/plugins/test_accounts.py index b25cdb0950..a321646eb8 100644 --- a/tests/plugins/test_accounts.py +++ b/tests/plugins/test_accounts.py @@ -6,7 +6,7 @@ from kinto.core.testing import get_user_headers from pyramid.exceptions import ConfigurationError -from kinto.plugins.accounts import scripts, ACCOUNT_CACHE_KEY +from kinto.plugins.accounts import scripts, ACCOUNT_CACHE_KEY, ACCOUNT_VALIDATION_CACHE_KEY from kinto.plugins.accounts.utils import hash_password from .. import support @@ -182,6 +182,11 @@ def test_authentication_refresh_the_cache_each_time_we_authenticate(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 test_create_account_fails_if_not_email(self): activation_form_url = "https://example.com/" resp = self.app.post_json( @@ -219,7 +224,7 @@ def test_create_account_stores_activated_field(self): status=201, ) assert resp.json["data"]["activation-form-url"] == activation_form_url - assert resp.json["data"]["activation-key"] == uuid_string + assert "activation-key" not in resp.json["data"] assert "validated" in resp.json["data"] assert not resp.json["data"]["validated"] mailer_call = self.get_mailer().send.call_args_list[0] @@ -227,6 +232,8 @@ def test_create_account_stores_activated_field(self): assert mailer_call[0][0].subject == "activate your account" assert mailer_call[0][0].recipients == ["alice@example.com"] assert mailer_call[0][0].body == f"{activation_form_url}{uuid_string}" + # The activation key is stored in the cache. + assert self.get_account_validation_cache("alice@example.com") == uuid_string def test_cant_authenticate_with_unactivated_account(self): self.app.post_json( @@ -268,6 +275,8 @@ def test_validation_fail_bad_activation_key(self): "/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 self.get_account_validation_cache("alice@example.com") is not None def test_validation_validates_user(self): # On user activation the 'validated' field is set to True. @@ -292,6 +301,8 @@ def test_validation_validates_user(self): # 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 self.get_account_validation_cache("alice@example.com") is None def test_validation_fail_active_user(self): uuid_string = "20e81ab7-51c0-444f-b204-f1c4cfe1aa7a"