DEV Community

Cover image for Authentication system using Python (Django) and SvelteKit - User Profile & Password Update
John Owolabi Idogun
John Owolabi Idogun

Posted on

Authentication system using Python (Django) and SvelteKit - User Profile & Password Update

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:

GitHub logo Sirneij / django-auth-backend

Django session-based authentication system with SvelteKit frontend

django-auth-backend

CI Test coverage

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
    Enter fullscreen mode Exit fullscreen mode

    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 
    Enter fullscreen mode Exit fullscreen mode

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) 
Enter fullscreen mode Exit fullscreen mode

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, ) 
Enter fullscreen mode Exit fullscreen mode

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">&nbsp;</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">&nbsp;</td> </tr> <tr> <td align="left"> <p align="center">&nbsp;</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">&nbsp;</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> 
Enter fullscreen mode Exit fullscreen mode

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', ) 
Enter fullscreen mode Exit fullscreen mode

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', } ) 
Enter fullscreen mode Exit fullscreen mode

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', ), ... ] 
Enter fullscreen mode Exit fullscreen mode

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)