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)