Repository: cdriehuys/django-simple-email-auth

Issue:
Add endpoints for resetting a password
As a user of the API, I would like to be able to reset my password if I forget it.

### Acceptance

1. Sending a `POST` request to `/password-reset-requests/` with an email address should send a new password reset token to that email address if the email address has been verified.
2. If the email address has not been verified or does not exist, no action should be taken.
3. Sending a `POST` request to `/accounts/password-resets/` with a valid password reset token and new password should set the password for the user associated with the token.
4. If the token is invalid, an error message should be returned and the password should be unchanged.
5. If the new password does not pass Django's password validation checks, the password change should be rejected.

Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 1ec9e5e..8cbae66 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -16,6 +16,7 @@ Features
   interface.
 * :issue:`38`: Add endpoint to resend a verification email to the provided REST
   interface.
+* :issue:`39`: Add endpoints to request a password reset and reset a password.
 
 ******
 v0.3.1
diff --git a/README.rst b/README.rst
index d7551a2..f062e55 100644
--- a/README.rst
+++ b/README.rst
@@ -41,6 +41,7 @@ Features
 A brief overview of the features offered by this package:
 
 * Verification of email ownership
+* Authentication using any verified email address
 * Password resets
 
 ************
diff --git a/email_auth/interfaces/rest/serializers.py b/email_auth/interfaces/rest/serializers.py
index c2aefec..c29dd91 100644
--- a/email_auth/interfaces/rest/serializers.py
+++ b/email_auth/interfaces/rest/serializers.py
@@ -1,13 +1,19 @@
+import logging
 from typing import Optional
 
 import email_utils
 from django.conf import settings
+from django.contrib.auth import password_validation
+from django.core.exceptions import ValidationError
 from django.utils.translation import ugettext_lazy as _
 from rest_framework import serializers
 
 from email_auth import models
 
 
+logger = logging.getLogger(__name__)
+
+
 class EmailVerificationRequestSerializer(serializers.Serializer):
     """
     Serializer to request re-sending of a verification email.
@@ -106,3 +112,93 @@ class EmailVerificationSerializer(serializers.Serializer):
             )
 
         return token
+
+
+class PasswordResetRequestSerializer(serializers.Serializer):
+    """
+    Serializer to create and send a password reset token to a verified
+    email address.
+    """
+
+    email = serializers.EmailField()
+
+    def save(self, **kwargs) -> Optional[models.PasswordReset]:
+        """
+        Send a new password reset token to the provided email address if
+        the email has already been verified. If the provided email has
+        not been verified, no action is taken.
+
+        Returns:
+            The created :py:class:`PasswordReset` instance if one was
+            created or else ``None``.
+        """
+        try:
+            email = models.EmailAddress.objects.get(
+                address__iexact=self.validated_data["email"], is_verified=True
+            )
+        except models.EmailAddress.DoesNotExist:
+            return None
+
+        reset = models.PasswordReset(email=email)
+        reset.send_email()
+
+        return reset
+
+
+class PasswordResetSerializer(serializers.Serializer):
+    """
+    Serializer to reset a user's password.
+    """
+
+    password = serializers.CharField(
+        style={"input_type": "password"}, write_only=True
+    )
+    token = serializers.CharField(
+        max_length=models.TOKEN_LENGTH, write_only=True
+    )
+
+    _reset: models.PasswordReset = None
+
+    def save(self, **kwargs):
+        """
+        Reset the password of the user associated with the provided
+        password reset token.
+        """
+        reset = self._reset
+        user = reset.email.user
+        user.set_password(self.validated_data["password"])
+        user.save()
+
+        logger.info("Reset the password for user %r", user)
+
+        reset.delete()
+
+    def validate(self, attrs: dict) -> dict:
+        """
+        Ensure that the provided token is valid and the provided
+        password passes Django's built in password validation.
+
+        Args:
+            attrs:
+                The data to validate.
+
+        Returns:
+            The validated data.
+        """
+        try:
+            self._reset = models.PasswordReset.objects.get(
+                token=attrs["token"]
+            )
+        except models.PasswordReset.DoesNotExist:
+            raise serializers.ValidationError(
+                {"token": _("The provided password reset token is invalid.")}
+            )
+
+        try:
+            password_validation.validate_password(
+                attrs["password"], user=self._reset.email.user
+            )
+        except ValidationError as e:
+            raise serializers.ValidationError({"password": e.messages})
+
+        return attrs
diff --git a/email_auth/interfaces/rest/urls.py b/email_auth/interfaces/rest/urls.py
index ed2fcda..a74653c 100644
--- a/email_auth/interfaces/rest/urls.py
+++ b/email_auth/interfaces/rest/urls.py
@@ -16,4 +16,14 @@ urlpatterns = [
         views.EmailVerificationView.as_view(),
         name="email-verification-create",
     ),
+    path(
+        "password-reset-requests/",
+        views.PasswordResetRequestView.as_view(),
+        name="password-reset-request-list",
+    ),
+    path(
+        "password-resets/",
+        views.PasswordResetView.as_view(),
+        name="password-reset-list",
+    ),
 ]
diff --git a/email_auth/interfaces/rest/views.py b/email_auth/interfaces/rest/views.py
index 5a29da0..7f6deec 100644
--- a/email_auth/interfaces/rest/views.py
+++ b/email_auth/interfaces/rest/views.py
@@ -34,3 +34,37 @@ class EmailVerificationView(generics.CreateAPIView):
     """
 
     serializer_class = serializers.EmailVerificationSerializer
+
+
+class PasswordResetRequestView(generics.CreateAPIView):
+    """
+    post:
+    Request a password reset token be sent to the provided email
+    address.
+
+    If the provided email address exists and is verified, a new password
+    reset token will be generated and sent to the provided address. In
+    any other case, no email will be sent. Regardless of the outcome, a
+    `201` response is returned.
+
+    If the provided email address is not a valid email address, a `400`
+    response is returned.
+    """
+
+    serializer_class = serializers.PasswordResetRequestSerializer
+
+
+class PasswordResetView(generics.CreateAPIView):
+    """
+    post:
+    Reset a user's password using a password reset token.
+
+    If the provided token is valid and the new password passes the
+    password validation checks, a `201` response is returned and the
+    user's password is changed.
+
+    If the provided token is invalid or the new password does not pass
+    validation, a `400` response is returned.
+    """
+
+    serializer_class = serializers.PasswordResetSerializer


Test patch:
diff --git a/email_auth/conftest.py b/email_auth/conftest.py
index 39ce0c9..c7d78bf 100644
--- a/email_auth/conftest.py
+++ b/email_auth/conftest.py
@@ -23,3 +23,12 @@ def mock_email_verification_qs():
         "email_auth.models.EmailVerification.objects", new=mock_qs
     ):
         yield mock_qs
+
+
+@pytest.fixture
+def mock_password_reset_qs():
+    mock_qs = mock.Mock(spec=models.PasswordReset.objects)
+    mock_qs.all.return_value = mock_qs
+
+    with mock.patch("email_auth.models.PasswordReset.objects", new=mock_qs):
+        yield mock_qs
diff --git a/email_auth/interfaces/rest/test/serializers/test_password_reset_request_serializer.py b/email_auth/interfaces/rest/test/serializers/test_password_reset_request_serializer.py
new file mode 100644
index 0000000..4251375
--- /dev/null
+++ b/email_auth/interfaces/rest/test/serializers/test_password_reset_request_serializer.py
@@ -0,0 +1,78 @@
+from unittest import mock
+
+import pytest
+
+from email_auth import models
+
+
+pytest.importorskip("rest_framework")
+
+# Imports that require the presence of "rest_framework"
+from email_auth.interfaces.rest import serializers  # noqa
+
+
+def test_save_unregistered_email(mock_email_address_qs):
+    """
+    If the provided email address doesn't exist in the system, saving
+    should do nothing.
+    """
+    address = "test@example.com"
+    mock_email_address_qs.get.side_effect = models.EmailAddress.DoesNotExist
+
+    data = {"email": address}
+    serializer = serializers.PasswordResetRequestSerializer(data=data)
+
+    assert serializer.is_valid()
+    result = serializer.save()
+
+    assert result is None
+    assert serializer.data == data
+    assert mock_email_address_qs.get.call_args[1] == {
+        "address__iexact": address,
+        "is_verified": True,
+    }
+
+
+def test_save_unverified_email(mock_email_address_qs):
+    """
+    If the provided email address has not been verified yet, saving the
+    serializer should do nothing.
+    """
+    address = "test@example.com"
+    mock_email_address_qs.get.side_effect = models.EmailAddress.DoesNotExist
+
+    data = {"email": address}
+    serializer = serializers.PasswordResetRequestSerializer(data=data)
+
+    assert serializer.is_valid()
+    result = serializer.save()
+
+    assert result is None
+    assert serializer.data == data
+    assert mock_email_address_qs.get.call_args[1] == {
+        "address__iexact": address,
+        "is_verified": True,
+    }
+
+
+@mock.patch("email_auth.models.PasswordReset.send_email")
+def test_save_verified_email(_, mock_email_address_qs):
+    """
+    If a verified email is provided, saving the serializer should send
+    a new password reset token to the provided address.
+    """
+    email = models.EmailAddress(address="test@example.com")
+    mock_email_address_qs.get.return_value = email
+
+    data = {"email": email.address}
+    serializer = serializers.PasswordResetRequestSerializer(data=data)
+
+    assert serializer.is_valid()
+    result = serializer.save()
+
+    assert serializer.data == data
+    assert result.send_email.call_count == 1
+    assert mock_email_address_qs.get.call_args[1] == {
+        "address__iexact": email.address,
+        "is_verified": True,
+    }
diff --git a/email_auth/interfaces/rest/test/serializers/test_password_reset_serializer.py b/email_auth/interfaces/rest/test/serializers/test_password_reset_serializer.py
new file mode 100644
index 0000000..1867b29
--- /dev/null
+++ b/email_auth/interfaces/rest/test/serializers/test_password_reset_serializer.py
@@ -0,0 +1,82 @@
+from unittest import mock
+
+import pytest
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ValidationError
+
+from email_auth import models
+
+
+pytest.importorskip("rest_framework")
+
+from email_auth.interfaces.rest import serializers  # noqa
+
+
+# Since the serializer uses Django's built in password validators, we
+# need a password that will pass them.
+NEW_PASSWORD = "MySup3rSecurePassword"
+
+
+@mock.patch("django.contrib.auth.models.User.save", autospec=True)
+@mock.patch("email_auth.models.PasswordReset.delete", autospec=True)
+def test_save_valid_token(_, __, mock_password_reset_qs):
+    """
+    Saving the serializer with a valid token should reset the password
+    of the user associated with the reset.
+    """
+    user = get_user_model()(username="Test User")
+    email = models.EmailAddress(user=user)
+    reset = models.PasswordReset(email=email)
+    mock_password_reset_qs.get.return_value = reset
+
+    data = {"password": NEW_PASSWORD, "token": reset.token}
+    serializer = serializers.PasswordResetSerializer(data=data)
+
+    assert serializer.is_valid()
+    serializer.save()
+
+    assert serializer.data == {}
+    assert user.check_password(NEW_PASSWORD)
+    assert mock_password_reset_qs.get.call_args[1] == {"token": reset.token}
+    assert user.save.call_count == 1
+    assert reset.delete.call_count == 1
+
+
+@mock.patch(
+    "email_auth.interfaces.rest.serializers.password_validation.validate_password",  # noqa
+    autospec=True,
+    side_effect=ValidationError("Invalid password"),
+)
+def test_validate(mock_validate_password, mock_password_reset_qs):
+    """
+    If the provided token is valid, the provided password should be run
+    through Django's password validation system.
+    """
+    user = get_user_model()()
+    email = models.EmailAddress(user=user)
+    reset = models.PasswordReset(email=email)
+    mock_password_reset_qs.get.return_value = reset
+
+    data = {"password": NEW_PASSWORD, "token": reset.token}
+    serializer = serializers.PasswordResetSerializer(data=data)
+
+    assert not serializer.is_valid()
+    assert set(serializer.errors.keys()) == {"password"}
+    assert mock_password_reset_qs.get.call_args[1] == {"token": reset.token}
+    assert mock_validate_password.call_args[0][0] == NEW_PASSWORD
+    assert mock_validate_password.call_args[1] == {"user": user}
+
+
+def test_validate_invalid_token(mock_password_reset_qs):
+    """
+    If the provided token is not valid, a validation error should be
+    raised.
+    """
+    token = "foo"
+    mock_password_reset_qs.get.side_effect = models.PasswordReset.DoesNotExist
+
+    data = {"password": NEW_PASSWORD, "token": token}
+    serializer = serializers.PasswordResetSerializer(data=data)
+
+    assert not serializer.is_valid()
+    assert set(serializer.errors.keys()) == {"token"}
diff --git a/email_auth/interfaces/rest/test/views/test_password_reset_request_view.py b/email_auth/interfaces/rest/test/views/test_password_reset_request_view.py
new file mode 100644
index 0000000..a77e082
--- /dev/null
+++ b/email_auth/interfaces/rest/test/views/test_password_reset_request_view.py
@@ -0,0 +1,84 @@
+import pytest
+import requests
+from django.contrib.auth import get_user_model
+
+from email_auth import models
+
+
+pytest.importorskip("rest_framework")
+
+from email_auth.interfaces.rest import serializers, views  # noqa
+
+
+def test_get_serializer_class():
+    """
+    Test the serializer class used by the view.
+    """
+    view = views.PasswordResetRequestView()
+    expected = serializers.PasswordResetRequestSerializer
+
+    assert view.get_serializer_class() == expected
+
+
+@pytest.mark.functional_test
+def test_request_password_reset(live_server, mailoutbox, settings):
+    """
+    If the user provides a verified email address to the endpoint, an
+    email containing a link to the password reset page should be sent.
+    """
+    reset_url_template = "http://localhost/reset-password/{key}"
+    settings.EMAIL_AUTH = {"PASSWORD_RESET_URL": reset_url_template}
+
+    user = get_user_model().objects.create_user(username="Test User")
+    email = models.EmailAddress.objects.create(
+        address="test@example.com", is_verified=True, user=user
+    )
+
+    data = {"email": email.address}
+    url = f"{live_server}/rest/password-reset-requests/"
+    response = requests.post(url, data)
+
+    assert response.status_code == 201
+    assert response.json() == data
+    assert len(mailoutbox) == 1
+
+    msg = mailoutbox[0]
+    reset = models.PasswordReset.objects.get()
+
+    assert msg.to == [data["email"]]
+    assert reset_url_template.format(key=reset.token) in msg.body
+
+
+@pytest.mark.functional_test
+def test_request_password_reset_missing_email(live_server, mailoutbox):
+    """
+    If the user provides an email address that does not exist in the
+    system, no action should be taken.
+    """
+    data = {"email": "test@example.com"}
+    url = f"{live_server}/rest/password-reset-requests/"
+    response = requests.post(url, data)
+
+    assert response.status_code == 201
+    assert response.json() == data
+    assert len(mailoutbox) == 0
+
+
+@pytest.mark.functional_test
+def test_request_password_reset_unverified_email(live_server, mailoutbox):
+    """
+    If the user provides an email address that does not exist in the
+    system, no action should be taken.
+    """
+    user = get_user_model().objects.create_user(username="Test User")
+    email = models.EmailAddress.objects.create(
+        address="test@example.com", is_verified=False, user=user
+    )
+
+    data = {"email": email.address}
+    url = f"{live_server}/rest/password-reset-requests/"
+    response = requests.post(url, data)
+
+    assert response.status_code == 201
+    assert response.json() == data
+    assert len(mailoutbox) == 0
diff --git a/email_auth/interfaces/rest/test/views/test_password_reset_view.py b/email_auth/interfaces/rest/test/views/test_password_reset_view.py
new file mode 100644
index 0000000..5df9026
--- /dev/null
+++ b/email_auth/interfaces/rest/test/views/test_password_reset_view.py
@@ -0,0 +1,69 @@
+import pytest
+import requests
+from django.contrib.auth import get_user_model
+
+from email_auth import models
+
+
+pytest.importorskip("rest_framework")
+
+from email_auth.interfaces.rest import serializers, views  # noqa
+
+
+def test_get_serializer_class():
+    """
+    Test the serializer class used by the view.
+    """
+    view = views.PasswordResetView()
+    expected = serializers.PasswordResetSerializer
+
+    assert view.get_serializer_class() == expected
+
+
+@pytest.mark.functional_test
+def test_reset_password(live_server):
+    """
+    Sending a ``POST`` request to the endpoint with a valid reset token
+    and a valid new password should reset the user's password.
+    """
+    user = get_user_model().objects.create_user(username="Test User")
+    email = models.EmailAddress.objects.create(
+        address="test@example.com", is_verified=True, user=user
+    )
+    reset = models.PasswordReset.objects.create(email=email)
+
+    data = {"password": "NewPassword", "token": reset.token}
+    url = f"{live_server}/rest/password-resets/"
+    response = requests.post(url, data)
+
+    assert response.status_code == 201
+    assert response.json() == {}
+
+    user.refresh_from_db()
+
+    assert user.check_password(data["password"])
+
+
+@pytest.mark.functional_test
+def test_reset_password_invalid_token(live_server):
+    """
+    Sending a ``POST`` request to the endpoint with an invalid token
+    should return a 400 response and not change the user's password.
+    """
+    password = "password"
+    user = get_user_model().objects.create_user(
+        password=password, username="Test User"
+    )
+
+    data = {"password": password * 2, "token": "invalid-token"}
+    url = f"{live_server}/rest/password-resets/"
+    response = requests.post(url, data)
+
+    assert response.status_code == 400
+    assert response.json() == {
+        "token": ["The provided password reset token is invalid."]
+    }
+
+    # Password shouldn't have changed
+    user.refresh_from_db()
+    assert user.check_password(password)