Authentication system using Python (Django) and SvelteKit - User Profile & Password Update
Tags: githubhack23, python, django, webdev
Published: 2023-05-19 09:04:41

## 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][1] 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][2] 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 Sirneij/django-auth-backend %}

## 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:

```python
# 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][3] 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][2]. 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][4]. `PATCH` is used for [PARTIAL][5] 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][1] which is available in [`django.http.multipartparser`][6]. The class expects four (4) arguments at initialization &mdash; 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 &mdash; 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:

```python
# 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:

```html
<!-- 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>
```

Next is the `src/users/views/password/confirm_change.py`:

```python
# 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:

```python
# 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.

```python
# 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 ☕](mailto:sirneij@gmail.com). You can also connect with/follow me on [LinkedIn](https://www.linkedin.com/in/idogun-john-nelson/) and [Twitter](https://twitter.com/Sirneij). It isn't bad if you help share this article for wider coverage. I will appreciate it...

[1]: https://github.com/django/django/blob/fce90950bef348803fa7cc3e6bc65f4bce429b82/django/http/multipartparser.py#L46 "MultiPartParser"
[2]: https://dev.to/sirneij/series/22690 "Secure and performant full-stack authentication system using rust (actix-web) and sveltekit Series' Articles"
[3]: https://docs.djangoproject.com/en/4.2/ref/csrf/ "Cross-Site Request Forgery protection"
[4]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods "HTTP request methods"
[5]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH "PATCH"
[6]: https://github.com/django/django/blob/main/django/http/multipartparser.py "django.http.multipartparser"