What to do when user forgot the password? We need to have a reset password view. User enters there her email address and email with reset link is sent. The reset link contains special information like uid
and token
. When user clicks the reset link, the reset password confirm view is displayed. This view takes the uid
and token
from the link and a new password and send POST
request to the Django server to set a new password. In this post, we will create the reset password functionality with Django Rest Framework and Djoser package.
What will you learn in this post:
- how does reset password work in the web service,
- how to send reset password email,
- how to set a new password with reset link,
- how to test the above functionality.
The user interface and production email setup will be descibed in the next posts (in this post only backend code will be provided). Fill the form to be notified about future posts.
This article is a part of articles series on how to build SaaS from scratch with Django and React. I will use the code from the previous article: Django Rest Framework Email Verification.
Reset Password Flow
In the backend there are two endpoints for reset password functionality. Both endpoints are provided by the Djoser package:
- Reset Password endpoint, that is available at
/users/reset_password/
URL. The enpoint requires theemail
field to send an email with reset password link. It has variablePASSWORD_RESET_SHOW_EMAIL_NOT_FOUND
in the settings, by default set toFalse
. It controls what type of error returns in the case ofemail
address in the request which doesn’t exist in the database - by default it will returnHTTP_204_NO_CONTENT
and doesn’t send any email. If you want different behaviour, just change this variable. - Reset Password Confirmation endpoint, that is available at
/users/reset_password_confirm/
. We need to send inPOST
request theuid
andtoken
from reset link to this endpoint. We also need to send a new password provided by user. If all goes well, this endpoint will returnHTTP_204_NO_CONTENT
.
The reset password functionality flow is presented below:
To enable reset password functionality we need to configure Djoser
in backend/server/server/settings.py
file. We need to set PASSWORD_RESET_CONFIRM_URL
variable that points to the URL address in the frontend with reset password confirm view.
# backend/server/server/settings.py
# ...
# configure Djoser
DJOSER = {
"USER_ID_FIELD": "username",
"LOGIN_FIELD": "email",
"SEND_ACTIVATION_EMAIL": True,
"ACTIVATION_URL": "activate/{uid}/{token}",
"PASSWORD_RESET_CONFIRM_URL": "reset_password/{uid}/{token}", # the reset link
'SERIALIZERS': {
'token_create': 'apps.accounts.serializers.CustomTokenCreateSerializer',
},
}
# ...
The example of reset password email:
Subject: Password reset on SaaSitive
Body:
You're receiving this email because you requested a password reset for your user account at SaaSitive.
Please go to the following page and choose a new password:
http://testserver/reset_password/MQ/afnd93-8d1799decda7137bbf57c047ed33ee86
Your username, in case you've forgotten: test_user
Thanks for using our site!
The SaaSitive team
The link to reset the password:
http://testserver/reset_password/MQ/afnd93-8d1799decda7137bbf57c047ed33ee86
From our reset link:
uid = MQ
,token = afnd93-8d1799decda7137bbf57c047ed33ee86
.
Don’t worry that the reset link has http://testserver
- it is only for testing purposes. It will be set to the correct domain address in production.
Test reset password functionality
We are ready to write tests for reset password functionality. We will write three tests:
- test for normal flow, the registered user with active account request the password reset,
- test for reset password of inactive user (email not verified),
- test for reset password with not existing email address.
Let’s add tests in backend/server/apps/accounts/tests.py
file. Please add a new class PasswordResetTest
(above the EmailVerificationTest
class from the previous post).
# backend/server/apps/accounts/tests.py
# ...
class PasswordResetTest(APITestCase):
# endpoints needed
register_url = "/api/v1/users/"
activate_url = "/api/v1/users/activation/"
login_url = "/api/v1/token/login/"
send_reset_password_email_url = "/api/v1/users/reset_password/"
confirm_reset_password_url = "/api/v1/users/reset_password_confirm/"
# user infofmation
user_data = {
"email": "test@example.com",
"username": "test_user",
"password": "verysecret"
}
login_data = {
"email": "test@example.com",
"password": "verysecret"
}
def test_reset_password(self):
# register the new user
response = self.client.post(self.register_url, self.user_data, format="json")
# expected response
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# expected one email to be send
self.assertEqual(len(mail.outbox), 1)
# parse email to get uid and token
email_lines = mail.outbox[0].body.splitlines()
activation_link = [l for l in email_lines if "/activate/" in l][0]
uid, token = activation_link.split("/")[-2:]
# verify email
data = {"uid": uid, "token": token}
response = self.client.post(self.activate_url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
# reset password
data = {"email": self.user_data["email"]}
response = self.client.post(self.send_reset_password_email_url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
# parse reset-password email to get uid and token
# it is a second email!
email_lines = mail.outbox[1].body.splitlines()
reset_link = [l for l in email_lines if "/reset_password/" in l][0]
uid, token = activation_link.split("/")[-2:]
# confirm reset password
data = {"uid": uid, "token": token, "new_password": "new_verysecret"}
response = self.client.post(self.confirm_reset_password_url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
# login to get the authentication token with old password
response = self.client.post(self.login_url, self.login_data, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
# login to get the authentication token with new password
login_data = dict(self.login_data)
login_data["password"] = "new_verysecret"
response = self.client.post(self.login_url, login_data, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
# ...
Steps in the test_reset_password
:
- register a new user,
- parse activation email,
- activate the user,
- request email with reset password link,
- parse reset password email,
- confirm reset password with
uid
,token
andnew_password
, - try to login with old password (fails with
HTTP_400_BAD_REQUEST
), - try to login with new password (success with
HTTP_200_OK
).
Test the functionality for inactive user (without verified email):
# backend/server/apps/accounts/tests.py
# ...
def test_reset_password_inactive_user(self):
# register the new user
response = self.client.post(self.register_url, self.user_data, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# reset password for inactive user
data = {"email": self.user_data["email"]}
response = self.client.post(self.send_reset_password_email_url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
# the email wasnt send
self.assertEqual(len(mail.outbox), 1)
# ...
Please notice that there is HTTP_204_NO_CONTENT
response but no email is sent. There is only one email in mail.outbox
and it is activation email from registration step.
Test the functionality for a wrong email:
# backend/server/apps/accounts/tests.py
# ...
def test_reset_password_wrong_email(self):
data = {"email": "wrong@email.com"}
response = self.client.post(self.send_reset_password_email_url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
# the email wasnt send
self.assertEqual(len(mail.outbox), 0)
# ...
Similar like above the HTTP_204_NO_CONTENT
is returned and no email is sent. To run our tests please use the following command:
./manage.py test apps
The expected output:
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.......
----------------------------------------------------------------------
Ran 7 tests in 1.765s
OK
Destroying test database for alias 'default'...
We have 3 tests for reset password and 4 tests for email verification functionality.
Commit changes to the repository
That’s all. We have reset password functionality ready and tested. We need to commit changes to the repository:
git commit -am "reset password in the backend"
git push
Summary
- We configured
Djoser
for reset password functionality (by setting one variable!). - We have two endpoints: reset password for sending email with reset link and reset password confirm to set a new password.
- New functionality was tested with unit tests.
- We will write user interface in React for this functionality in the next post.
The code for this tutorial is available at Github repository.
Let's stay in touch!
Would you like to be notified about new posts? Please fill this form.
Have you found a bug in the code? Please add a GitHub issue.
Do you have problems with running the code or setup and need help? Please add a StackOverflow question with django-react tag.
Boilerplate Tutorial Articles:
1. Starting SaaS with Django and React
2. React Routing and Components for Signup and
Login
3. Token Based Authentication with
Django Rest Framework and Djoser
4. React Token Based Authentication to Django REST
API Backend
5. React Authenticated Component
6. CRUD in Django Rest Framework and React
7. Docker-Compose for Django and React with
Nginx reverse-proxy and Let's encrypt certificate
8. Django Rest Framework Email Verification
9. Django Rest Framework Reset Password
(↩ you are here!)
More articles coming soon!
Link to the code repository: saasitive/django-react-boilerplate