Skip to content

Commit

Permalink
Reset password endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
faucomte97 committed Jan 29, 2024
1 parent 558feaf commit 3e35be0
Show file tree
Hide file tree
Showing 6 changed files with 202 additions and 16 deletions.
2 changes: 1 addition & 1 deletion backend/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
from .auth_factor import AuthFactorSerializer
from .klass import ClassSerializer
from .school import SchoolSerializer
from .user import UserSerializer
from .user import UserSerializer, PasswordResetSerializer
34 changes: 34 additions & 0 deletions backend/api/serializers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
Created on 18/01/2024 at 15:14:32(+00:00).
"""

from codeforlife.serializers import ModelSerializer
from codeforlife.user.auth.password_validators import (
IndependentStudentPasswordValidator,
TeacherPasswordValidator,
)
from codeforlife.user.serializers import UserSerializer as _UserSerializer
from django.contrib.auth.models import User
from rest_framework import serializers


Expand All @@ -16,3 +22,31 @@ class Meta(_UserSerializer.Meta):
*_UserSerializer.Meta.fields,
"current_password",
]


# pylint: disable-next=missing-class-docstring
class PasswordResetSerializer(ModelSerializer[User]):
class Meta:
model = User
fields = ["password"]
extra_kwargs = {"password": {"write_only": True}}

def validate_password(self, value: str):
"""
Validate the new password depending on user type.
:param value: the new password
"""
user = getattr(self, "instance", None)
validator = (
TeacherPasswordValidator
if hasattr(user, "new_teacher")
else IndependentStudentPasswordValidator
)()

validator.validate(value)
return value

def update(self, instance, validated_data):
instance.set_password(validated_data["password"])
instance.save()
return instance
11 changes: 3 additions & 8 deletions backend/api/tests/views/test_auth_factor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,9 @@
from codeforlife.user.models import AuthFactor, User, UserProfile
from rest_framework import status

from ...serializers import AuthFactorSerializer
from ...views import AuthFactorViewSet


# pylint: disable-next=missing-class-docstring
class TestAuthFactorViewSet(
ModelViewSetTestCase[AuthFactorViewSet, AuthFactorSerializer, AuthFactor]
):
class TestAuthFactorViewSet(ModelViewSetTestCase[AuthFactor]):
basename = "auth-factor"

def setUp(self):
Expand Down Expand Up @@ -75,7 +70,7 @@ def test_list(self):

def test_create(self):
"""
Can enable a auth-factor.
Can enable an auth-factor.
"""

user = self.client.login(**self.one_factor_credentials)
Expand All @@ -85,7 +80,7 @@ def test_create(self):

def test_destroy(self):
"""
Can disable a auth-factor.
Can disable an auth-factor.
"""

user = self.client.login(**self.two_factor_credentials)
Expand Down
92 changes: 88 additions & 4 deletions backend/api/tests/views/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@

from codeforlife.tests import ModelViewSetTestCase
from codeforlife.user.models import User
from rest_framework import status

from ...serializers import UserSerializer
from ...views import UserViewSet


class TestUserViewSet(ModelViewSetTestCase[UserViewSet, UserSerializer, User]):
class TestUserViewSet(ModelViewSetTestCase[User]):
"""
Base naming convention:
test_{action}
Expand Down Expand Up @@ -41,3 +39,89 @@ def test_is_unique_email(self):
)

self.assertTrue(response.json())

def test_request_password_reset(self):
"""
Check request password reset generates a reset password URL, if email exists.
"""
user = User.objects.first()
assert user is not None

viewname = self.client.reverse("request-password-reset")

response = self.client.post(
viewname, data={"email": "[email protected]"}
)

assert response.data is None

response = self.client.post(viewname, data={"email": user.email})

assert response.data["reset_password_url"] is not None
assert response.data["uidb64"] is not None
assert response.data["token"] is not None

def test_reset_password(self):
"""
Check reset password logic: requires valid encoded uid, user token and check password update.
"""
user = User.objects.first()
assert user is not None

# Generate a password reset URL
viewname = self.client.reverse("request-password-reset")

response = self.client.post(viewname, data={"email": user.email})
reset_password_url = response.data["reset_password_url"]

# Test reset-password GET
# Check invalid uid raises 400
viewname = self.client.reverse(
"reset-password",
kwargs={"uidb64": "whatever", "token": response.data["token"]},
)

invalid_uid_response = self.client.get(
viewname, status_code_assertion=status.HTTP_400_BAD_REQUEST
)

assert invalid_uid_response.data["non_field_errors"] == [
"no user found for given uid"
]

# Check invalid token raises 400
viewname = self.client.reverse(
"reset-password",
kwargs={"uidb64": response.data["uidb64"], "token": "whatever"},
)

invalid_token_response = self.client.get(
viewname, status_code_assertion=status.HTTP_400_BAD_REQUEST
)

assert invalid_token_response.data["non_field_errors"] == [
"token doesn't match given user"
]

# Check successful GET
self.client.get(reset_password_url)

# Test reset-password PATCH for teacher
self.client.patch(reset_password_url, data={"password": "N3wPassword!"})
self.client.login(email=user.email, password="N3wPassword!")
self.client.logout()

user = User.objects.get(id=11)
assert user is not None

# Generate a password reset URL
viewname = self.client.reverse("request-password-reset")

response = self.client.post(viewname, data={"email": user.email})
reset_password_url = response.data["reset_password_url"]

self.client.get(reset_password_url)

# Test reset-password PATCH for indy
self.client.patch(reset_password_url, data={"password": "N3wPassword"})
self.client.login(email=user.email, password="N3wPassword")
77 changes: 75 additions & 2 deletions backend/api/views/user.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import typing as t

from codeforlife.user.models import User
from codeforlife.user.views import UserViewSet as _UserViewSet
from django.contrib.auth.models import User
from django.contrib.auth.tokens import default_token_generator
from django.core.exceptions import ObjectDoesNotExist
from django.utils.encoding import force_bytes, force_str
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
from rest_framework.response import Response

from ..serializers import UserSerializer
from ..serializers import UserSerializer, PasswordResetSerializer


class UserViewSet(_UserViewSet):
Expand All @@ -21,3 +26,71 @@ def is_unique_email(self, request):
return Response(
email and not User.objects.filter(email__iexact=email).exists()
)

@action(
detail=False,
methods=["get", "patch"],
url_path="reset-password/(?P<uidb64>[a-zA-Z0-9]+)-(?P<token>.+)",
permission_classes=[AllowAny],
)
def reset_password(self, request, uidb64=None, token=None):
def _find_user_from_uidb64(uidb64):
uid = force_str(urlsafe_base64_decode(uidb64))
return User.objects.get(pk=uid)

if request.method == "GET":
try:
user = _find_user_from_uidb64(uidb64)
except (TypeError, ValueError, OverflowError, ObjectDoesNotExist):
return Response(
{"non_field_errors": ["no user found for given uid"]},
status=status.HTTP_400_BAD_REQUEST,
)

if not default_token_generator.check_token(user, token):
return Response(
{"non_field_errors": ["token doesn't match given user"]},
status=status.HTTP_400_BAD_REQUEST,
)

return Response()

serializer = PasswordResetSerializer(
_find_user_from_uidb64(uidb64), data=request.data
)
serializer.is_valid(raise_exception=True)
serializer.save()

# TODO: Check if need to handle resetting ratelimit and unblocking of user

return Response()

@action(detail=False, methods=["post"], permission_classes=[AllowAny])
def request_password_reset(self, request):
"""
Generates a reset password URL to be emailed to the user if the
given email address exists.
"""
email = request.data.get("email")

try:
user = User.objects.get(email=email)
except ObjectDoesNotExist:
# NOTE: Always return a 200 here - a noticeable change in behaviour would allow email enumeration.
return Response()

uidb64 = urlsafe_base64_encode(force_bytes(user.pk))
token = default_token_generator.make_token(user)

reset_password_url = self.reverse_action(
"reset-password", kwargs={"uidb64": uidb64, "token": token}
)

# TODO: Send email to user with URL to reset password.
return Response(
{
"reset_password_url": reset_password_url,
"uidb64": uidb64,
"token": token,
}
)
2 changes: 1 addition & 1 deletion run
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ set -e

cd "${BASH_SOURCE%/*}"

source ./setup.sh
source ./setup

cd frontend

Expand Down

0 comments on commit 3e35be0

Please sign in to comment.