diff --git a/apps/api/plane/authentication/views/app/password_management.py b/apps/api/plane/authentication/views/app/password_management.py index 48b54dcccb4..10f718079c9 100644 --- a/apps/api/plane/authentication/views/app/password_management.py +++ b/apps/api/plane/authentication/views/app/password_management.py @@ -18,7 +18,7 @@ from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.http import HttpResponseRedirect -from django.utils.encoding import DjangoUnicodeDecodeError, smart_bytes, smart_str +from django.utils.encoding import smart_bytes, smart_str from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from django.views import View @@ -98,79 +98,75 @@ def post(self, request): class ResetPasswordEndpoint(View): def post(self, request, uidb64, token): + """Set a new password for the user encoded in uidb64. + + Always redirects: to sign-in on success, back to the reset-password page with an error code when the + link or the submitted password is rejected. + """ try: # Decode the id from the uidb64 - try: - id = smart_str(urlsafe_base64_decode(uidb64)) - user = User.objects.get(id=id) - except (ValueError, User.DoesNotExist): - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"], - error_message="INVALID_PASSWORD_TOKEN", - ) - params = exc.get_error_dict() - url = urljoin( - base_host(request=request, is_app=True), - "accounts/reset-password?" + urlencode(params), - ) - return HttpResponseRedirect(url) - - # check if the token is valid for the user - if not PasswordResetTokenGenerator().check_token(user, token): - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"], - error_message="INVALID_PASSWORD_TOKEN", - ) - params = exc.get_error_dict() - url = urljoin( - base_host(request=request, is_app=True), - "accounts/reset-password?" + urlencode(params), - ) - return HttpResponseRedirect(url) - - password = request.POST.get("password", False) - - if not password: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], - error_message="INVALID_PASSWORD", - ) - url = urljoin( - base_host(request=request, is_app=True), - "accounts/reset-password?" + urlencode(exc.get_error_dict()), - ) - return HttpResponseRedirect(url) - - # Check the password complexity - results = zxcvbn(password) - if results["score"] < 3: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"], - error_message="PASSWORD_TOO_WEAK", - ) - url = urljoin( - base_host(request=request, is_app=True), - "accounts/reset-password?" + urlencode(exc.get_error_dict()), - ) - return HttpResponseRedirect(url) - - # set_password also hashes the password that the user will get - user.set_password(password) - user.is_password_autoset = False - user.save() + id = smart_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(id=id) + except (ValueError, ValidationError, User.DoesNotExist): + # Malformed base64, bytes that are not utf-8 (DjangoUnicodeDecodeError subclasses ValueError), + # a non-UUID id or an id that matches no user + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"], + error_message="INVALID_PASSWORD_TOKEN", + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "accounts/reset-password?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + # check if the token is valid for the user + if not PasswordResetTokenGenerator().check_token(user, token): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"], + error_message="INVALID_PASSWORD_TOKEN", + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "accounts/reset-password?" + urlencode(params), + ) + return HttpResponseRedirect(url) + password = request.POST.get("password", False) + + if not password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + ) url = urljoin( base_host(request=request, is_app=True), - "sign-in?" + urlencode({"success": True}), + "accounts/reset-password?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) - except DjangoUnicodeDecodeError: + + # Check the password complexity + results = zxcvbn(password) + if results["score"] < 3: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["EXPIRED_PASSWORD_TOKEN"], - error_message="EXPIRED_PASSWORD_TOKEN", + error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"], + error_message="PASSWORD_TOO_WEAK", ) url = urljoin( base_host(request=request, is_app=True), "accounts/reset-password?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) + + # set_password also hashes the password that the user will get + user.set_password(password) + user.is_password_autoset = False + user.save() + + url = urljoin( + base_host(request=request, is_app=True), + "sign-in?" + urlencode({"success": True}), + ) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/password_management.py b/apps/api/plane/authentication/views/space/password_management.py index ed6682d74ae..b1059b811a7 100644 --- a/apps/api/plane/authentication/views/space/password_management.py +++ b/apps/api/plane/authentication/views/space/password_management.py @@ -18,7 +18,7 @@ from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.http import HttpResponseRedirect -from django.utils.encoding import DjangoUnicodeDecodeError, smart_bytes, smart_str +from django.utils.encoding import smart_bytes, smart_str from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from django.views import View @@ -110,51 +110,58 @@ def post(self, request): class ResetPasswordSpaceEndpoint(View): def post(self, request, uidb64, token): + """Set a new password for the user encoded in uidb64. + + Always redirects: to the space host on success, back to the reset-password page with an error code when + the link or the submitted password is rejected. + """ try: # Decode the id from the uidb64 id = smart_str(urlsafe_base64_decode(uidb64)) user = User.objects.get(id=id) + except (ValueError, ValidationError, User.DoesNotExist): + # Malformed base64, bytes that are not utf-8 (DjangoUnicodeDecodeError subclasses ValueError), + # a non-UUID id or an id that matches no user + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"], + error_message="INVALID_PASSWORD_TOKEN", + ) + url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501 + return HttpResponseRedirect(url) - # check if the token is valid for the user - if not PasswordResetTokenGenerator().check_token(user, token): - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"], - error_message="INVALID_PASSWORD_TOKEN", - ) - params = exc.get_error_dict() - url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(params)}" - return HttpResponseRedirect(url) - - password = request.POST.get("password", False) - - if not password: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], - error_message="INVALID_PASSWORD", - ) - url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501 - return HttpResponseRedirect(url) - - # Check the password complexity - results = zxcvbn(password) - if results["score"] < 3: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"], - error_message="PASSWORD_TOO_WEAK", - ) - url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501 - return HttpResponseRedirect(url) - - # set_password also hashes the password that the user will get - user.set_password(password) - user.is_password_autoset = False - user.save() - - return HttpResponseRedirect(base_host(request=request, is_space=True)) - except DjangoUnicodeDecodeError: + # check if the token is valid for the user + if not PasswordResetTokenGenerator().check_token(user, token): exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["EXPIRED_PASSWORD_TOKEN"], - error_message="EXPIRED_PASSWORD_TOKEN", + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"], + error_message="INVALID_PASSWORD_TOKEN", + ) + params = exc.get_error_dict() + url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(params)}" + return HttpResponseRedirect(url) + + password = request.POST.get("password", False) + + if not password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + ) + url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501 + return HttpResponseRedirect(url) + + # Check the password complexity + results = zxcvbn(password) + if results["score"] < 3: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"], + error_message="PASSWORD_TOO_WEAK", ) url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501 return HttpResponseRedirect(url) + + # set_password also hashes the password that the user will get + user.set_password(password) + user.is_password_autoset = False + user.save() + + return HttpResponseRedirect(base_host(request=request, is_space=True)) diff --git a/apps/api/plane/tests/contract/app/test_password_reset.py b/apps/api/plane/tests/contract/app/test_password_reset.py new file mode 100644 index 00000000000..b10450f7b62 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_password_reset.py @@ -0,0 +1,358 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import uuid +from urllib.parse import parse_qs, urlparse + +import pytest +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.test import Client +from django.utils.encoding import DjangoUnicodeDecodeError, smart_bytes, smart_str +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode + +from plane.authentication.adapter.error import AUTHENTICATION_ERROR_CODES +from plane.db.models import User + +EXPECTED_ORIGIN = ("http", "testserver") + +STRONG_PASSWORD = "correct-horse-battery-staple-9x" +WEAK_PASSWORD = "password" + +# Two ways a uidb64 fails before the user lookup - same except clause, different raise +# sites, pinned by test_uidb64_fixtures_reach_their_branches +MALFORMED_UIDB64 = "a" # not valid base64: urlsafe_base64_decode raises binascii.Error +UNDECODABLE_UIDB64 = "not" # decodes to b"ž‹": smart_str raises DjangoUnicodeDecodeError + +# Where each endpoint sends a rejected reset, and where it sends a successful one. +# The trailing slashes differ because the space endpoint builds its redirects +# with f-strings and the app endpoint with urljoin() - compared as emitted. +SPACE_ERROR_PATH = "/spaces/accounts/reset-password/" +SPACE_SUCCESS_PATH = "/spaces/" +APP_ERROR_PATH = "/accounts/reset-password" +APP_SUCCESS_PATH = "/sign-in" + + +@pytest.fixture(autouse=True) +def _pin_web_url(settings): + """Pin the redirect host so base_host() does not depend on the ambient environment""" + settings.WEB_URL = "http://testserver" + settings.SPACE_BASE_URL = None + settings.APP_BASE_URL = None + settings.SPACE_BASE_PATH = "/spaces/" + + +@pytest.fixture +def django_client(): + """Return a Django test client with a User-Agent header for handling redirects""" + return Client(HTTP_USER_AGENT="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1") + + +@pytest.fixture +def reset_user(db): + """Create a user that owns a valid password reset link""" + user = User.objects.create(email="reset-user@plane.so", is_password_autoset=True) + user.set_password("user@123") + user.save() + return user + + +def _encode(value): + """Encode a user id the way generate_password_token() does""" + return urlsafe_base64_encode(smart_bytes(value)) + + +def _space_url(uidb64, token): + """Build the space reset-password path. + + Three patterns share name="forgot-password" and two share + name="space-forgot-password", so reverse() picks between them by argument + count alone. Hardcoding the paths pins the URL contract these tests assert. + """ + return f"/auth/spaces/reset-password/{uidb64}/{token}/" + + +def _app_url(uidb64, token): + """Build the app reset-password path - see _space_url for why reverse() is not used""" + return f"/auth/reset-password/{uidb64}/{token}/" + + +def _error_query(error_code_key): + """Return the query string both endpoints attach to a rejected reset""" + return {"error_code": [str(AUTHENTICATION_ERROR_CODES[error_code_key])], "error_message": [error_code_key]} + + +def _assert_redirect(response, expected_path, expected_query): + """Assert the response redirects to expected_path on the pinned origin, carrying exactly expected_query. + + Only the doubled slash after the space base path is normalized: base_host() + already ends in a slash, so the space endpoint emits + "/spaces//accounts/reset-password/". That quirk predates these tests and is + not what they pin - but every other path is compared as emitted, so the same + defect appearing anywhere else does fail. + """ + assert response.status_code == 302 + location = urlparse(response["Location"]) + assert (location.scheme, location.netloc) == EXPECTED_ORIGIN + assert location.path.replace("/spaces//", "/spaces/", 1) == expected_path + assert parse_qs(location.query, keep_blank_values=True) == expected_query + + +def _assert_credentials_untouched(user, password_hash): + """Assert a rejected reset left the stored hash and the autoset flag alone""" + user.refresh_from_db() + assert user.password == password_hash + assert user.is_password_autoset is True + + +@pytest.mark.contract +def test_error_code_wire_values(): + """Pin the numbers clients hardcode - packages/constants/src/auth/index.ts carries the same literals""" + assert AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"] == 5125 + assert AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"] == 5020 + assert AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"] == 5021 + + +@pytest.mark.contract +def test_uidb64_fixtures_reach_their_branches(): + """Pin that the two rejected-uidb64 fixtures fail where their names say they do. + + Both endpoints answer either one with INVALID_PASSWORD_TOKEN, so the response + alone no longer tells them apart: without this, a change that stopped + UNDECODABLE_UIDB64 from raising at all would leave the utf-8 branch untested + and every test still green. + """ + with pytest.raises(ValueError) as malformed: + urlsafe_base64_decode(MALFORMED_UIDB64) + assert not isinstance(malformed.value, DjangoUnicodeDecodeError) + + with pytest.raises(DjangoUnicodeDecodeError): + smart_str(urlsafe_base64_decode(UNDECODABLE_UIDB64)) + + +@pytest.mark.contract +class TestResetPasswordSpaceEndpoint: + """The space reset-password endpoint must redirect - never 500 - on a bad uidb64""" + + @pytest.mark.django_db + def test_unknown_user_id_redirects(self, django_client, reset_user): + """A well formed uidb64 for a user that does not exist redirects with INVALID_PASSWORD_TOKEN""" + password_hash = reset_user.password + uidb64 = _encode(uuid.uuid4()) + token = PasswordResetTokenGenerator().make_token(reset_user) + + response = django_client.post(_space_url(uidb64, token), {"password": STRONG_PASSWORD}) + + _assert_redirect(response, SPACE_ERROR_PATH, _error_query("INVALID_PASSWORD_TOKEN")) + _assert_credentials_untouched(reset_user, password_hash) + + @pytest.mark.django_db + def test_non_uuid_id_redirects(self, django_client, reset_user): + """A uidb64 that decodes to something that is not a UUID redirects instead of raising ValidationError""" + password_hash = reset_user.password + uidb64 = _encode("not-a-uuid") + token = PasswordResetTokenGenerator().make_token(reset_user) + + response = django_client.post(_space_url(uidb64, token), {"password": STRONG_PASSWORD}) + + _assert_redirect(response, SPACE_ERROR_PATH, _error_query("INVALID_PASSWORD_TOKEN")) + _assert_credentials_untouched(reset_user, password_hash) + + @pytest.mark.django_db + def test_malformed_base64_redirects(self, django_client, reset_user): + """A uidb64 that is not decodable base64 redirects instead of raising ValueError""" + password_hash = reset_user.password + token = PasswordResetTokenGenerator().make_token(reset_user) + + response = django_client.post(_space_url(MALFORMED_UIDB64, token), {"password": STRONG_PASSWORD}) + + _assert_redirect(response, SPACE_ERROR_PATH, _error_query("INVALID_PASSWORD_TOKEN")) + _assert_credentials_untouched(reset_user, password_hash) + + @pytest.mark.django_db + def test_undecodable_uidb64_redirects(self, django_client, reset_user): + """A uidb64 that decodes to invalid utf-8 is rejected as an invalid link""" + password_hash = reset_user.password + token = PasswordResetTokenGenerator().make_token(reset_user) + + response = django_client.post(_space_url(UNDECODABLE_UIDB64, token), {"password": STRONG_PASSWORD}) + + _assert_redirect(response, SPACE_ERROR_PATH, _error_query("INVALID_PASSWORD_TOKEN")) + _assert_credentials_untouched(reset_user, password_hash) + + @pytest.mark.django_db + def test_missing_password_redirects(self, django_client, reset_user): + """A valid link without a password is still rejected with INVALID_PASSWORD""" + password_hash = reset_user.password + uidb64 = _encode(reset_user.id) + token = PasswordResetTokenGenerator().make_token(reset_user) + + response = django_client.post(_space_url(uidb64, token), {}) + + _assert_redirect(response, SPACE_ERROR_PATH, _error_query("INVALID_PASSWORD")) + _assert_credentials_untouched(reset_user, password_hash) + + @pytest.mark.django_db + def test_invalid_token_redirects(self, django_client, reset_user): + """An existing user with a token that does not belong to them is still rejected""" + password_hash = reset_user.password + uidb64 = _encode(reset_user.id) + + response = django_client.post(_space_url(uidb64, "invalid-token"), {"password": STRONG_PASSWORD}) + + _assert_redirect(response, SPACE_ERROR_PATH, _error_query("INVALID_PASSWORD_TOKEN")) + _assert_credentials_untouched(reset_user, password_hash) + + @pytest.mark.django_db + def test_weak_password_redirects(self, django_client, reset_user): + """A valid link with a weak password does not change the password""" + password_hash = reset_user.password + uidb64 = _encode(reset_user.id) + token = PasswordResetTokenGenerator().make_token(reset_user) + + response = django_client.post(_space_url(uidb64, token), {"password": WEAK_PASSWORD}) + + _assert_redirect(response, SPACE_ERROR_PATH, _error_query("PASSWORD_TOO_WEAK")) + _assert_credentials_untouched(reset_user, password_hash) + + @pytest.mark.django_db + def test_valid_link_resets_password(self, django_client, reset_user): + """A valid link with a strong password still resets the password""" + uidb64 = _encode(reset_user.id) + token = PasswordResetTokenGenerator().make_token(reset_user) + + response = django_client.post(_space_url(uidb64, token), {"password": STRONG_PASSWORD}) + + _assert_redirect(response, SPACE_SUCCESS_PATH, {}) + reset_user.refresh_from_db() + assert reset_user.check_password(STRONG_PASSWORD) + assert reset_user.is_password_autoset is False + + @pytest.mark.django_db + def test_token_cannot_be_replayed(self, django_client, reset_user): + """A token stops working once it has been spent - it is hashed over the stored password""" + uidb64 = _encode(reset_user.id) + token = PasswordResetTokenGenerator().make_token(reset_user) + django_client.post(_space_url(uidb64, token), {"password": STRONG_PASSWORD}) + reset_user.refresh_from_db() + password_hash = reset_user.password + + response = django_client.post(_space_url(uidb64, token), {"password": "another-correct-horse-99x"}) + + _assert_redirect(response, SPACE_ERROR_PATH, _error_query("INVALID_PASSWORD_TOKEN")) + reset_user.refresh_from_db() + assert reset_user.password == password_hash + + +@pytest.mark.contract +class TestResetPasswordAppEndpoint: + """The app reset-password endpoint must redirect - never 500 - on a bad uidb64""" + + @pytest.mark.django_db + def test_non_uuid_id_redirects(self, django_client, reset_user): + """A uidb64 that decodes to something that is not a UUID redirects instead of raising ValidationError""" + password_hash = reset_user.password + uidb64 = _encode("not-a-uuid") + token = PasswordResetTokenGenerator().make_token(reset_user) + + response = django_client.post(_app_url(uidb64, token), {"password": STRONG_PASSWORD}) + + _assert_redirect(response, APP_ERROR_PATH, _error_query("INVALID_PASSWORD_TOKEN")) + _assert_credentials_untouched(reset_user, password_hash) + + @pytest.mark.django_db + def test_unknown_user_id_redirects(self, django_client, reset_user): + """A well formed uidb64 for a user that does not exist redirects with INVALID_PASSWORD_TOKEN""" + password_hash = reset_user.password + uidb64 = _encode(uuid.uuid4()) + token = PasswordResetTokenGenerator().make_token(reset_user) + + response = django_client.post(_app_url(uidb64, token), {"password": STRONG_PASSWORD}) + + _assert_redirect(response, APP_ERROR_PATH, _error_query("INVALID_PASSWORD_TOKEN")) + _assert_credentials_untouched(reset_user, password_hash) + + @pytest.mark.django_db + def test_malformed_base64_redirects(self, django_client, reset_user): + """A uidb64 that is not decodable base64 redirects instead of raising ValueError""" + password_hash = reset_user.password + token = PasswordResetTokenGenerator().make_token(reset_user) + + response = django_client.post(_app_url(MALFORMED_UIDB64, token), {"password": STRONG_PASSWORD}) + + _assert_redirect(response, APP_ERROR_PATH, _error_query("INVALID_PASSWORD_TOKEN")) + _assert_credentials_untouched(reset_user, password_hash) + + @pytest.mark.django_db + def test_undecodable_uidb64_redirects(self, django_client, reset_user): + """A uidb64 that decodes to invalid utf-8 is rejected as an invalid link""" + password_hash = reset_user.password + token = PasswordResetTokenGenerator().make_token(reset_user) + + response = django_client.post(_app_url(UNDECODABLE_UIDB64, token), {"password": STRONG_PASSWORD}) + + _assert_redirect(response, APP_ERROR_PATH, _error_query("INVALID_PASSWORD_TOKEN")) + _assert_credentials_untouched(reset_user, password_hash) + + @pytest.mark.django_db + def test_invalid_token_redirects(self, django_client, reset_user): + """An existing user with a token that does not belong to them is still rejected""" + password_hash = reset_user.password + uidb64 = _encode(reset_user.id) + + response = django_client.post(_app_url(uidb64, "invalid-token"), {"password": STRONG_PASSWORD}) + + _assert_redirect(response, APP_ERROR_PATH, _error_query("INVALID_PASSWORD_TOKEN")) + _assert_credentials_untouched(reset_user, password_hash) + + @pytest.mark.django_db + def test_missing_password_redirects(self, django_client, reset_user): + """A valid link without a password is still rejected with INVALID_PASSWORD""" + password_hash = reset_user.password + uidb64 = _encode(reset_user.id) + token = PasswordResetTokenGenerator().make_token(reset_user) + + response = django_client.post(_app_url(uidb64, token), {}) + + _assert_redirect(response, APP_ERROR_PATH, _error_query("INVALID_PASSWORD")) + _assert_credentials_untouched(reset_user, password_hash) + + @pytest.mark.django_db + def test_weak_password_redirects(self, django_client, reset_user): + """A valid link with a weak password does not change the password""" + password_hash = reset_user.password + uidb64 = _encode(reset_user.id) + token = PasswordResetTokenGenerator().make_token(reset_user) + + response = django_client.post(_app_url(uidb64, token), {"password": WEAK_PASSWORD}) + + _assert_redirect(response, APP_ERROR_PATH, _error_query("PASSWORD_TOO_WEAK")) + _assert_credentials_untouched(reset_user, password_hash) + + @pytest.mark.django_db + def test_valid_link_resets_password(self, django_client, reset_user): + """A valid link with a strong password still resets the password""" + uidb64 = _encode(reset_user.id) + token = PasswordResetTokenGenerator().make_token(reset_user) + + response = django_client.post(_app_url(uidb64, token), {"password": STRONG_PASSWORD}) + + _assert_redirect(response, APP_SUCCESS_PATH, {"success": ["True"]}) + reset_user.refresh_from_db() + assert reset_user.check_password(STRONG_PASSWORD) + assert reset_user.is_password_autoset is False + + @pytest.mark.django_db + def test_token_cannot_be_replayed(self, django_client, reset_user): + """A token stops working once it has been spent - it is hashed over the stored password""" + uidb64 = _encode(reset_user.id) + token = PasswordResetTokenGenerator().make_token(reset_user) + django_client.post(_app_url(uidb64, token), {"password": STRONG_PASSWORD}) + reset_user.refresh_from_db() + password_hash = reset_user.password + + response = django_client.post(_app_url(uidb64, token), {"password": "another-correct-horse-99x"}) + + _assert_redirect(response, APP_ERROR_PATH, _error_query("INVALID_PASSWORD_TOKEN")) + reset_user.refresh_from_db() + assert reset_user.password == password_hash