Skip to content

Commit 02d0d9b

Browse files
authored
Merge pull request #140 from codersforcauses/i139_password_reset_app
I139 password reset app
2 parents 8197818 + ec6843a commit 02d0d9b

12 files changed

Lines changed: 322 additions & 6 deletions

File tree

documentation/docs/backend/authentication.md renamed to documentation/docs/backend/authentication/authentication.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ This API is used to manage the authentication lifecycle. It allows us to:
1313

1414
/api/auth
1515

16+
----
17+
1618
**Endpoints:**
1719

1820
## /register/
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Authentication REST API
2+
3+
----
4+
This API is used to manage the password reset lifecycle. It allows us to:
5+
6+
* Send a password reset email with an SMTP server (currently configured for gmail)
7+
* Verfiy that a token and associated uid is valid
8+
* Reset the user password using a valid token and associated uid
9+
10+
----
11+
**Base URL:**
12+
13+
/api/auth/reset
14+
15+
----
16+
17+
**Endpoints:**
18+
19+
## /
20+
21+
Methods: POST, PUT
22+
Description: PUT to veriy that a token/uid pair is valid. POST to change passwords.
23+
24+
**URL Parameters**
25+
26+
| Key | Data Type | Description |
27+
| :---- | :-------: | :----------------------------------------------------- |
28+
| token | String | A password reset token. |
29+
| uid | String | The url-safe base64 encoded email address of the user. |
30+
31+
**Data Parameters:**
32+
33+
| Key | Data Type | Description |
34+
| :------- | :-------: | :---------------- |
35+
| password | String | The new password. |
36+
37+
Example request
38+
39+
```json
40+
{
41+
"password": "123456",
42+
}
43+
```
44+
45+
Example response
46+
47+
```json
48+
// TODO
49+
```
50+
51+
## /email/
52+
53+
Methods: POST
54+
Description: Register an user. Returns the user JSON as the response.
55+
Success status code: 200 OK
56+
57+
**Data Parameters:**
58+
59+
| Key | Data Type | Description |
60+
| :---- | :-------: | :--------------------- |
61+
| email | String | The email of the user. |

server/api/apps/auth/reset_password/__init__.py

Whitespace-only changes.

server/api/apps/auth/reset_password/migrations/__init__.py

Whitespace-only changes.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from rest_framework import serializers
2+
from django.contrib.auth.password_validation import validate_password
3+
4+
5+
class ChangePasswordSerializer(serializers.Serializer):
6+
password = serializers.CharField(required=True)
7+
8+
def validate_password(self, value):
9+
validate_password(value)
10+
return value
11+
12+
13+
class EmailSerializer(serializers.Serializer):
14+
email = serializers.EmailField()
15+
16+
class Meta:
17+
fields = "email"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from django.urls import path
2+
from .views import ResetPasswordView, EmailResetLinkView
3+
4+
urlpatterns = [
5+
path("email/", EmailResetLinkView.as_view(), name="send-reset-email"),
6+
path(
7+
"",
8+
ResetPasswordView.as_view(),
9+
name="reset",
10+
),
11+
]
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
from smtplib import SMTPAuthenticationError
2+
3+
from django.conf import settings
4+
from django.contrib.auth import get_user_model
5+
from django.contrib.auth.tokens import PasswordResetTokenGenerator
6+
from django.core.mail import send_mail
7+
from django.utils.encoding import force_bytes, force_str
8+
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
9+
from rest_framework import generics, status
10+
from rest_framework.response import Response
11+
12+
from .serializers import ChangePasswordSerializer, EmailSerializer
13+
14+
15+
# Create your views here.
16+
class ResetPasswordView(generics.RetrieveUpdateAPIView):
17+
"""
18+
An endpoint for changing password.
19+
"""
20+
21+
serializer_class = ChangePasswordSerializer
22+
23+
def validate_token(self, request):
24+
token = request.GET.get("token", "").rstrip("/")
25+
uid = None
26+
try:
27+
uidb64 = request.GET.get("uid", "").rstrip("/")
28+
if uidb64 != "":
29+
uid = force_str(urlsafe_base64_decode(uidb64))
30+
except ValueError:
31+
pass
32+
33+
user = get_user_model().objects.filter(email=uid).first()
34+
35+
is_valid_token = PasswordResetTokenGenerator().check_token(
36+
token=token, user=user
37+
)
38+
return is_valid_token, user
39+
40+
def get(self, request, *args, **kwargs):
41+
is_valid_token, user = self.validate_token(request)
42+
if not is_valid_token:
43+
return Response(
44+
{
45+
"message": (
46+
"the password reset token is invalid or has expired."
47+
)
48+
},
49+
status=status.HTTP_400_BAD_REQUEST,
50+
)
51+
52+
return Response(
53+
{"message": "the password reset token is valid"},
54+
status=status.HTTP_200_OK,
55+
)
56+
57+
def update(self, request, *args, **kwargs):
58+
is_valid_token, user = self.validate_token(request)
59+
if not is_valid_token:
60+
return Response(
61+
{
62+
"message": (
63+
"the password reset token is invalid or has expired."
64+
)
65+
},
66+
status=status.HTTP_400_BAD_REQUEST,
67+
)
68+
69+
serializer = self.get_serializer(data=request.data)
70+
71+
if not serializer.is_valid():
72+
return Response(
73+
serializer.errors, status=status.HTTP_400_BAD_REQUEST
74+
)
75+
76+
if not user:
77+
return Response(
78+
{"message": "Email does not exist"},
79+
status=status.HTTP_400_BAD_REQUEST,
80+
)
81+
82+
user.set_password(serializer.data.get("password"))
83+
user.save()
84+
85+
return Response(
86+
{"message": "Password updated successfully"},
87+
status=status.HTTP_200_OK,
88+
)
89+
90+
91+
class EmailResetLinkView(generics.GenericAPIView):
92+
"""
93+
An endpoint for emailing reset password links.
94+
"""
95+
96+
serializer_class = EmailSerializer
97+
98+
def post(self, request):
99+
serializer = self.get_serializer(data=request.data)
100+
if not serializer.is_valid():
101+
return Response(
102+
serializer.errors, status=status.HTTP_400_BAD_REQUEST
103+
)
104+
105+
email = serializer.data["email"]
106+
user = get_user_model().objects.filter(email=email).first()
107+
108+
if not user:
109+
return Response(
110+
{"message": "Email does not exist"},
111+
status=status.HTTP_400_BAD_REQUEST,
112+
)
113+
114+
token = PasswordResetTokenGenerator().make_token(user)
115+
uid = urlsafe_base64_encode(force_bytes(user.email))
116+
117+
reset_url = f"{settings.FRONTEND_URL}/reset/?token={token}&uid={uid}/"
118+
119+
try:
120+
send_mail(
121+
"Elucidate Password Reset",
122+
f"Your password reset link can be found at: {reset_url}\n"
123+
+ "It will expire in"
124+
f" {settings.PASSWORD_RESET_TIMEOUT / 60} minutes.",
125+
settings.EMAIL_HOST_USER,
126+
[email],
127+
fail_silently=False,
128+
)
129+
except SMTPAuthenticationError:
130+
return Response(
131+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
132+
)
133+
134+
return Response(
135+
{"message": "Email has been sent"}, status=status.HTTP_200_OK
136+
)

server/api/apps/auth/serializers.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1+
from django.contrib.auth import get_user_model
12
from django.contrib.auth.password_validation import validate_password
23
from rest_framework import serializers
34
from rest_framework.serializers import ValidationError
45

5-
from api.apps.users.models import User
6-
76

87
class RegistrationSerializer(serializers.ModelSerializer):
98
class Meta:
10-
model = User
9+
model = get_user_model()
1110
fields = (
1211
"id",
1312
"email",
@@ -33,6 +32,6 @@ def validate_password(self, value):
3332
return value
3433

3534
def create(self, validated_data):
36-
user = User.objects.create_user(**validated_data)
35+
user = get_user_model().objects.create_user(**validated_data)
3736

3837
return user

server/api/apps/auth/tests/test_register_user.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from rest_framework.test import APITestCase
44

55

6-
class AuthTestCase(APITestCase):
6+
class RegisterTestCase(APITestCase):
77
def test_get_request(self):
88
"""
99
GIVEN: GET request to endpoint '/api/auth/register'
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from django.contrib.auth import get_user_model
2+
from django.contrib.auth.tokens import PasswordResetTokenGenerator
3+
from django.urls import reverse
4+
from django.utils.encoding import force_bytes
5+
from django.utils.http import urlsafe_base64_encode
6+
from rest_framework import status
7+
from rest_framework.test import APITestCase
8+
9+
10+
class ResetPasswordTestCase(APITestCase):
11+
def setUp(self):
12+
self.testuser = get_user_model().objects.create_user(
13+
email="john.doe@example.com",
14+
first_name="John",
15+
last_name="Doe",
16+
grade="Grade 11",
17+
password="password",
18+
)
19+
self.testuser.save()
20+
21+
def test_send_reset_email(self):
22+
"""
23+
GIVEN: The endpoint '/api/auth/reset/email/' and an active user
24+
WHEN: A POST request is sent to the endpoint with the HTTP body that
25+
contains an email address corresponding to a valid account
26+
THEN: The API HTTP Response Code should be 200
27+
AND: The SMTP server will be called to send a password reset email to
28+
the specified address
29+
"""
30+
url = reverse("send-reset-email")
31+
body = {"email": "john.doe@example.com"}
32+
33+
response = self.client.post(url, body, format="json")
34+
35+
self.assertEqual(response.status_code, status.HTTP_200_OK)
36+
37+
def test_verify_reset_token(self):
38+
"""
39+
WHEN: A GET request is sent to the endpoint with uid and token as url
40+
parameters
41+
THEN: Returns 200 status code if the token and uid is valid
42+
"""
43+
44+
user = (
45+
get_user_model()
46+
.objects.filter(email="john.doe@example.com")
47+
.first()
48+
)
49+
token = PasswordResetTokenGenerator().make_token(user)
50+
uid = urlsafe_base64_encode(force_bytes(user.email))
51+
52+
reset_url = f"{reverse('reset')}?token={token}&uid={uid}/"
53+
54+
response = self.client.get(reset_url)
55+
56+
self.assertEqual(response.status_code, status.HTTP_200_OK)
57+
58+
def test_reset_password(self):
59+
"""
60+
GIVEN: The endpoint '/api/auth/reset/' and a valid uid and token
61+
WHEN: A PUT request is sent to the endpoint with uid and token as url
62+
parameters and password in JSON body
63+
THEN: Returns 200 status code if the password has been reset.
64+
"""
65+
66+
user = (
67+
get_user_model()
68+
.objects.filter(email="john.doe@example.com")
69+
.first()
70+
)
71+
token = PasswordResetTokenGenerator().make_token(user)
72+
uid = urlsafe_base64_encode(force_bytes(user.email))
73+
74+
reset_url = f"{reverse('reset')}?token={token}&uid={uid}/"
75+
76+
body = {"password": "123456"}
77+
78+
response = self.client.put(reset_url, body, format="json")
79+
80+
self.assertEqual(response.status_code, status.HTTP_200_OK)

0 commit comments

Comments
 (0)