Introduction
Every user of an application will in most cases want to update their details such as profile picture, name, password if they notice a breach or when forgotten, and other data our application allows them to provide. In this article, we'll do just that by allowing our application's users to update their profile names (first and last), thumbnails, GitHub links, dates of birth, and passwords. We will learn how to utilize the sparingly documented (or more appropriately, undocumented) MultiPartParser to manually handle FormData
from PATCH
requests.
Assumption and Recommendation
It is assumed that you are familiar with Django. I also recommend you go through how we created the front end of the previous series as we'll only change a very few things there and will not delve much into how we pieced everything together. The APIs we'll build here mirror what we built in that series.
Source code
The source code for this series is hosted on GitHub via:
Sirneij / django-auth-backend
Django session-based authentication system with SvelteKit frontend
django-auth-backend
Django session-based authentication system with SvelteKit frontend and GitHub actions-based CI.
This app uses minimal dependencies (pure Django - no REST API framework) to build a secure, performant and reliable (with 100% automated test coverage, enforced static analysis using Python best uniform code standards) session-based authentication REST APIs which were then consumed by a SvelteKit-based frontend Application.
Users' profile images are uploaded directly to AWS S3 (in tests, we ditched S3 and used Django's InMemoryStorage for faster tests).
A custom password reset procedure was also incorporated, and Celery tasks did email sendings.
Run locally
-
To run the application, clone it:
git clone https://github.com/Sirneij/django-auth-backend.git
You can, if you want, grab its frontend counterpart.
-
Change the directory into the folder and create a virtual environment using either Python 3.9, 3.10 or 3.11 (tested against the three versions). Then activate it:
~django-auth-backend$ virtualenv -p python3.11 virtualenv ~django-auth-backend$ source virtualenv/bin/activate
…
Implementation
Step 1: User profile update
We will start with updating some non-critical parts of our app's users' data. Create profile_update.py
in users/views/
folder and populate it with:
# src/users/views/profile_update.py
import json
from io import BytesIO
from typing import Any
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpRequest, JsonResponse
from django.http.multipartparser import MultiPartParser
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from users.models import UserProfile
@method_decorator(csrf_exempt, name='dispatch')
class UserUpdateView(View, LoginRequiredMixin):
def patch(self, request: HttpRequest, **kwargs: dict[str, Any]) -> JsonResponse:
"""Handle user updates."""
if not request.user.is_authenticated:
return JsonResponse(
{'error': 'You are not logged in. Kindly ensure you are logged in and try again'}, status=401
)
data, files = MultiPartParser(
request.META, BytesIO(request.body), request.upload_handlers, request.encoding
).parse()
first_name = data.get('first_name')
last_name = data.get('last_name')
thumbnail = files.get('thumbnail')
phone_number = data.get('phone_number')
birth_date = data.get('birth_date')
github_link = data.get('github_link')
user_details = UserProfile.objects.filter(user=request.user).select_related('user').get()
if first_name:
user_details.user.first_name = first_name
if last_name:
user_details.user.last_name = last_name
if thumbnail:
user_details.user.thumbnail = thumbnail
user_details.user.save(update_fields=['first_name', 'last_name', 'thumbnail'])
if phone_number:
user_details.phone_number = phone_number
if birth_date:
user_details.birth_date = birth_date
if github_link:
user_details.github_link = github_link
user_details.save(update_fields=['phone_number', 'birth_date', 'github_link'])
res_data = {
'id': str(user_details.user.pk),
'email': user_details.user.email,
'first_name': user_details.user.first_name,
'last_name': user_details.user.last_name,
'is_staff': user_details.user.is_staff,
'is_active': user_details.user.is_active,
'date_joined': str(user_details.user.date_joined),
'thumbnail': user_details.user.thumbnail.url if user_details.user.thumbnail else None,
'profile': {
'id': str(user_details.id),
'user_id': str(user_details.user.pk),
'phone_number': user_details.phone_number,
'github_link': user_details.github_link,
'birth_date': str(user_details.birth_date) if user_details.birth_date else None,
},
}
response_data = json.loads(json.dumps(res_data))
return JsonResponse(response_data, status=200)
As usual, we started by ensuring that our API consumer does not need to provide CSRF token before accessing this page. I should tell you that we can actually provide csrftoken
in SvelteKit using the power of its form actions
. However, we just want to abide by the design principles adopted from the previous series. Next, we defined a PATCH
method in our generic View
class. PATCH
is preferred because we want to strictly follow the usage of HTTP verbs or methods. PATCH
is used for PARTIAL modification of resources. This endpoint should only be accessible to authenticated users and we enforced that at the very top of the method. Now, with the use of PATCH
, we cannot use the very convenient request.POST.get('key')
and request.FILES.get('key')
Django provides for retrieving POST
data and files respectively. If you use any of those here, they will be empty! We have to manually process the incoming PATCH
FormData
and to do that, we used the awesome but undocumented MultiPartParser which is available in django.http.multipartparser
. The class expects four (4) arguments at initialization — request's META
, body
(in its raw file-like nature), upload_handlers
(normally defaulted to request.upload_handlers
) and an encoding
(which has a default of settings.DEFAULT_CHARSET
). The META
's content type must start with multipart/
else it'll give errors. On a successful parse
of the supplied FormData
, it returns a tuple of data and files. We then retrieved the data and files and checked if any were provided. Updates were done accordingly.
You can then add this view to our list of URLs.
Step 2: User password change
Next is updating our users' passwords in case they were lost or something untoward happened to them. We'll require our users to undergo a three-step process — request a change with a registered and verified email address, click on the link sent to the email address, and input the new password.
By now, we are pretty familiar with allowing users to request something and we sending them emails with the appropriate link. This is not different. For the entire process, we will create a subpackage, called password
, in the views
package for brevity's sake. In the sub package, we will create request_change.py
, confirm_change_request.py
, and change_password.py
. The content of src/users/views/password/request_change.py
is:
# src/users/views/password/request_change.py
import json
from datetime import timedelta
from typing import Any
from asgiref.sync import sync_to_async
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site
from django.http import HttpRequest, JsonResponse
from django.urls.base import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from users.tasks import send_email_message
from users.token import account_activation_token
from users.utils import validate_email
@method_decorator(csrf_exempt, name='dispatch')
class RequestPasswordChangeView(View):
async def post(self, request: HttpRequest, **kwargs: dict[str, Any]) -> JsonResponse:
"""Request user password change."""
data = json.loads(request.body.decode("utf-8"))
email = data.get('email')
if email is None:
return JsonResponse({'error': 'Email field is empty'}, status=400)
is_valid, error_text = validate_email(email)
if not is_valid:
return JsonResponse({'error': error_text}, status=400)
try:
user = await get_user_model().objects.filter(email=email, is_active=True).aget()
except get_user_model().DoesNotExist:
return JsonResponse(
{
'error': 'An active user with this e-mail address does not exist. '
'If you registered with this email, ensure you have activated your account. '
'You can check by logging in. If you have not activated it, '
f'visit {settings.FRONTEND_URL}/auth/regenerate-token to '
'regenerate the token that will allow you activate your account.'
},
status=404,
)
token = await sync_to_async(account_activation_token.make_token)(user)
uid = urlsafe_base64_encode(force_bytes(user.pk))
confirmation_link = (
f"{request.scheme}://{get_current_site(request)}"
f"{reverse('users:confirm_password_change_request', kwargs={'uidb64': uid, 'token': token})}",
)
subject = 'Password reset instructions'
ctx = {
'title': "(Django) RustAuth - Password Reset Instructions",
'domain': settings.FRONTEND_URL,
'confirmation_link': confirmation_link,
'expiration_time': (timezone.localtime() + timedelta(seconds=settings.PASSWORD_RESET_TIMEOUT)).minute,
'exact_time': (timezone.localtime() + timedelta(seconds=settings.PASSWORD_RESET_TIMEOUT)).strftime(
'%A %B %d, %Y at %r'
),
}
send_email_message.delay(
subject=subject,
template_name='password_reset_email.html',
user_id=user.id,
ctx=ctx,
)
return JsonResponse(
{
'message': 'Password reset instructions have been sent to your email address. '
'Kindly take action before its expiration'
},
status=200,
)
It's basically almost like the logic for token regeneration aside from the fact that here, we are checking for an ACTIVE user with the provided email address. We also changed the email subject and the HTML template name. The template has this content:
<!-- src/templates/password_reset_email.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ title }}</title>
</head>
<body>
<table
style="
max-width: 555px;
width: 100%;
font-family: 'Open Sans', Segoe, 'Segoe UI', 'DejaVu Sans',
'Trebuchet MS', Verdana, sans-serif;
background: #fff;
font-size: 13px;
color: #323232;
"
cellspacing="0"
cellpadding="0"
border="0"
bgcolor="#ffffff"
align="center"
>
<tbody>
<tr>
<td align="left">
<h1 style="text-align: center">
<span style="font-size: 15px">
<strong>{{ title }}</strong>
</span>
</h1>
<p>
Your request to reset your password was submitted. If you did not
make this request, simply ignore this email. If you did make this
request just click the button below:
</p>
<table
style="
max-width: 555px;
width: 100%;
font-family: 'Open Sans', arial, sans-serif;
font-size: 13px;
color: #323232;
"
cellspacing="0"
cellpadding="0"
border="0"
bgcolor="#ffffff"
align="center"
>
<tbody>
<tr>
<td height="10"> </td>
</tr>
<tr>
<td style="text-align: center">
<a
href="{{ confirmation_link }}"
style="
color: #fff;
background-color: hsla(199, 69%, 84%, 1);
width: 320px;
font-size: 16px;
border-radius: 3px;
line-height: 44px;
height: 44px;
font-family: 'Open Sans', Arial, helvetica, sans-serif;
text-align: center;
text-decoration: none;
display: inline-block;
"
target="_blank"
data-saferedirecturl="https://www.google.com/url?q={{ confirmation_link }}"
>
<span style="color: #000000">
<strong>Change password</strong>
</span>
</a>
</td>
</tr>
</tbody>
</table>
<table
style="
max-width: 555px;
width: 100%;
font-family: 'Open Sans', arial, sans-serif;
font-size: 13px;
color: #323232;
"
cellspacing="0"
cellpadding="0"
border="0"
bgcolor="#ffffff"
align="center"
>
<tbody>
<tr>
<td height="10"> </td>
</tr>
<tr>
<td align="left">
<p align="center"> </p>
If the above button doesn't work, try copying and pasting
the link below into your browser. If you continue to
experience problems, please contact us.
<br />
{{ confirmation_link }}
<br />
</td>
</tr>
<tr>
<td>
<p align="center"> </p>
<br />
<p style="padding-bottom: 15px; margin: 0">
Kindly note that this link will expire in
<strong>{{expiration_time}} minutes</strong>. The exact
expiration date and time is:
<strong>{{ exact_time }}</strong>.
</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>
Next is the src/users/views/password/confirm_change.py
:
# src/users/views/password/confirm_change.py
from asgiref.sync import sync_to_async
from django.conf import settings
from django.contrib.auth import get_user_model
from django.http import HttpRequest, HttpResponseRedirect
from django.utils.encoding import force_bytes, force_str
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
from django.views import View
from users.token import account_activation_token
class ConfirmPasswordChangeRequestView(View):
async def get(self, request: HttpRequest, uidb64: str, token: str) -> HttpResponseRedirect:
"""Confirm password change requests."""
try:
uid = force_str(urlsafe_base64_decode(uidb64))
user = await get_user_model().objects.aget(pk=uid, is_active=True)
except (TypeError, ValueError, OverflowError, get_user_model().DoesNotExist):
user = None
if user is not None and account_activation_token.check_token(user, token):
# Generate a new token
token = await sync_to_async(account_activation_token.make_token)(user)
uid = urlsafe_base64_encode(force_bytes(user.pk))
combined = f'{uid}:{token}'
return HttpResponseRedirect(f'{settings.FRONTEND_URL}/auth/password/change-password?token={combined}')
return HttpResponseRedirect(
f'{settings.FRONTEND_URL}/auth/regenerate-token?reason=It appears that '
'your confirmation token has expired or previously used. Kindly generate a new token',
)
Also very familiar. The exemptions are that we are also checking ACTIVE users here. Then, if a user is successfully retrieved, we created a new token and encoded the user's id. Then we combined both using :
. This will help us detect the person requesting the password change later in the password change view. We then redirected the user to the frontend-end page for changing the password with the "combined" token as a query parameter. The front end sends this token back to the backend alongside the user's new password. Let's write the password-changing logic now:
# src/users/views/password/change_password.py
import json
from typing import Any
from django.contrib.auth import get_user_model
from django.http import HttpRequest, JsonResponse
from django.utils.decorators import method_decorator
from django.utils.encoding import force_str
from django.utils.http import urlsafe_base64_decode
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from users.token import account_activation_token
@method_decorator(csrf_exempt, name='dispatch')
class ChangePasswordView(View):
async def post(self, request: HttpRequest, **kwargs: dict[str, Any]) -> JsonResponse:
"""Change user password."""
data = json.loads(request.body.decode("utf-8"))
password = data.get('password')
combined = data.get('token')
try:
uidb64, token = combined.split(':')
uid = force_str(urlsafe_base64_decode(uidb64))
user = await get_user_model().objects.aget(pk=uid, is_active=True)
except (TypeError, ValueError, OverflowError, get_user_model().DoesNotExist):
user = None
if user is not None and account_activation_token.check_token(user, token):
user.set_password(password)
await user.asave(update_fields=['password'])
return JsonResponse(
{
'message': 'Your password has been changed successfully. Kindly login with the new password',
}
)
return JsonResponse(
{
'error': 'It appears that your password request token has expired or previously used',
}
)
We simply retrieved the new password and the "combined" token we sent previously. Then we tried to destructure the "combined" token to get the encoded user ID and the token itself. From there, fetched the user involved from the database, checked the correctness of the token and saved the user's password if everything is fine. That was pretty straightforward.
We can now add these views to our URLs to test them out.
# src/users/urls.py
...
from users.views.password import change_password, confirm_change_request, request_change
...
urlpatterns = [
...
# Password change
path(
'password-change/request-password-change/',
request_change.RequestPasswordChangeView.as_view(),
name='request_password_change',
),
path(
'password-change/confirm/change-password/<uidb64>/<token>/',
confirm_change_request.ConfirmPasswordChangeRequestView.as_view(),
name='confirm_password_change_request',
),
path(
'password-change/change-user-password/',
change_password.ChangePasswordView.as_view(),
name='change_password',
),
...
]
That's it for this article. I hope you enjoyed it. Up next is the automated testing and static analysis aspect with GitHub Actions. We'll also try to deploy our application freely on Vercel. See you then.
Outro
Enjoyed this article? Consider contacting me for a job, something worthwhile or buying a coffee ☕. You can also connect with/follow me on LinkedIn and Twitter. It isn't bad if you help share this article for wider coverage. I will appreciate it...
Top comments (0)