Authentication system using Python (Django) and SvelteKit - User Registration
Tags: githubhack23, python, webdev
Published: 2023-05-18 20:06:34

## Introduction

Having made the endpoints for users' logins and logouts, we'll look at registering users. But before then, we'll configure the admin page of our application to ensure that the required fields are shown there for managing users.

## 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][1] 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: Configure `users/admin.py` for our custom `User` model

At present, the admin page of our project will most likely configure our `User` model incorrectly, if it's configured at all. We need to correct that. Open `users/admin.py` and populate it with:

```python
# src/users/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin

from users.models import User, UserProfile


class CustomUserAdmin(UserAdmin):
    model = User
    list_display = (
        'email',
        'is_staff',
        'is_active',
    )
    list_filter = (
        'email',
        'is_staff',
        'is_active',
    )
    fieldsets = (
        (None, {'fields': ('email', 'password', 'thumbnail')}),
        ('Permissions', {'fields': ('is_staff', 'is_active')}),
    )
    add_fieldsets = (
        (
            None,
            {'classes': ('wide',), 'fields': ('email', 'password1', 'password2', 'thumbnail', 'is_staff', 'is_active')},
        ),
    )
    search_fields = ('email',)
    ordering = ('email',)


admin.site.register(User, CustomUserAdmin)


@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):  # type:ignore
    list_display = ('user_email', 'phone_number', 'github_link', 'birth_date')

    @admin.display(ordering="user__email")
    def user_email(self, obj: UserProfile) -> str:
        """Return the user's email."""
        return obj.user.email  # pragma: no cover
```

We are overriding the built-in `UserAdmin`, providing our custom fields in the important subsections. I recommend reading through the [documentation][2]. We also register our second model, `UserProfile`.

You can create a superuser now and access the admin site.

### Step 2: Registering users

It's time to create a user registration endpoint. The flow will be just like that of the [previous series][1]:

> Users register with email addresses, first and last names, and passwords. Then emails are sent. Clicking the links embedded in the emails activates such users.

Let's implement that in Django. As previously incepted, create `register.py` file in `users/views/` folder:

```python
# src/users/views/register.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 RegisterView(View):
    async def post(self, request: HttpRequest, **kwargs: dict[str, Any]) -> JsonResponse:
        """Register users."""
        data = json.loads(request.body.decode('utf-8'))
        email = data.get('email')
        first_name = data.get('first_name')
        last_name = data.get('last_name')
        password = data.get('password')

        # Some validations
        if email is None or password is None or first_name is None or last_name is None:
            return JsonResponse(
                {'error': 'All fields are required: email, first_name, last_name, password'}, status=400
            )

        is_valid, error_text = validate_email(email)
        if not is_valid:
            return JsonResponse({'error': error_text}, status=400)

        user_exists = await get_user_model().objects.filter(email=email).aexists()
        if user_exists:
            return JsonResponse({'error': 'A user with that email address already exists'}, status=400)

        user = await sync_to_async(get_user_model().objects.create_user)(
            email=email,
            password=password,
            first_name=first_name,
            last_name=last_name,
        )

        user.is_active = False
        await user.asave(update_fields=['is_active'])

        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', kwargs={'uidb64': uid, 'token': token})}",
        )

        subject = 'Please, verify your account'
        ctx = {
            'title': "(Django) RustAuth - Let's get you verified",
            '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='verification_email.html',
            user_id=user.id,
            ctx=ctx,
        )

        return JsonResponse(
            {
                'message': 'Your account was created successfully. '
                'Check your email address to activate your account as we '
                'just sent you an activation link. Ensure you activate your '
                'account before the link expires'
            },
            status=201,
        )
```

As usual, we retrieved the data from our request object and made some validations. For email address validation, we used a custom-made util function, `validate_email`, in `users/utils.py`:

```python
# src/users/utils.py
from django.core.exceptions import ValidationError
from django.core.validators import validate_email as django_validate_email


def validate_email(value: str) -> tuple[bool, str]:
    """Validate a single email."""
    message_invalid = 'Enter a valid email address.'

    if not value:
        return False, message_invalid
    # Check the regex, using the validate_email from django.
    try:
        django_validate_email(value)
    except ValidationError:
        return False, message_invalid

    return True, ''
```

It is pretty basic. Uses Django's `validate_email` to make such validations. If the email address is valid, we also checked whether or not a user with such an email address existed. If no such user existed, we proceeded to create a user with the provided credentials and deactivate such a user with `is_active` set to `False`. Next, we created a token for the user using the custom `account_activation_token` defined in `users/token.py`:

```python
# src/users/token.py
from django.contrib.auth.models import AbstractBaseUser
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from six import text_type


class AccountActivationTokenGenerator(PasswordResetTokenGenerator):
    def _make_hash_value(self, user: AbstractBaseUser, timestamp: int) -> str:
        return text_type(user.pk) + text_type(timestamp) + text_type(user.is_active)


account_activation_token = AccountActivationTokenGenerator()
```

which simply subclasses Django's `PasswordResetTokenGenerator`. We overrode the class's `_make_hash_value` so that the user's specific details will be included in the generated token. Next, we encoded the user's id and formed a confirmation link out of the token and the encoded id. This link is the most crucial thing we sent to the user's email address using the celery task, `send_email_message`:

```python
# src/users/tasks.py
from typing import Any
from uuid import UUID

from celery import shared_task
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.html import strip_tags


@shared_task
def send_email_message(subject: str, template_name: str, user_id: UUID, ctx: dict[str, Any]) -> None:
    """Send email to users."""
    html_message = render_to_string(template_name, ctx)
    plain_message = strip_tags(html_message)
    send_mail(
        subject=subject,
        message=plain_message,
        from_email=settings.DEFAULT_FROM_EMAIL,
        recipient_list=[get_user_model().objects.get(id=user_id).email],
        fail_silently=False,
        html_message=html_message,
    )
```

The task takes the user's id and other context data. From these data, we rendered the HTML template to be sent and used Django's `send_mail` to dispatch it.

The confirmation link sent was timed for expiration using the `PASSWORD_RESET_TIMEOUT` settings variable. When the user clicks the link, it redirects them to this view:

```python
# src/users/views/confirm.py
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_str
from django.utils.http import urlsafe_base64_decode
from django.views import View

from users.token import account_activation_token


class ConfirmEmailView(View):
    async def get(self, request: HttpRequest, uidb64: str, token: str) -> HttpResponseRedirect:
        """Confirm and activate user emails and accounts."""
        try:
            uid = force_str(urlsafe_base64_decode(uidb64))
            user = await get_user_model().objects.aget(pk=uid, is_active=False)
        except (TypeError, ValueError, OverflowError, get_user_model().DoesNotExist):
            user = None

        if user is not None and account_activation_token.check_token(user, token):
            user.is_active = True
            await user.asave(update_fields=['is_active'])
            return HttpResponseRedirect(f'{settings.FRONTEND_URL}/auth/confirmed')

        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',
        )
```

The view tries to retrieve the user's id from the `uidb64` parameter. Remember that it was this parameter that we encoded the user's id in the `RegisterView`. Having retrieved the id, we then tried to get a user with that id who has not been activated. If such a user exists and the user's token is valid, we activate such a user and redirect to the frontend page where a confirmation message is displayed. If otherwise, we prompted the user to a token regeneration page where an email is submitted and a token is regenerated for such a user if everything goes well:

```python
# src/users/views/regenerate.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 RegenerateTokenView(View):
    async def post(self, request: HttpRequest, **kwargs: dict[str, Any]) -> JsonResponse:
        """Regenerate tokens to unverified users."""
        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=False).aget()
        except get_user_model().DoesNotExist:
            return JsonResponse(
                {
                    'error': "A user with this e-mail address does not exist. "
                    "If you registered with this email, ensure you haven't activated it yet. "
                    "You can check by logging in"
                },
                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', kwargs={'uidb64': uid, 'token': token})}",
        )

        subject = 'Please, verify your account'
        ctx = {
            'title': "(Django) RustAuth - Let's get you verified",
            '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='verification_email.html',
            user_id=user.id,
            ctx=ctx,
        )

        return JsonResponse(
            {
                'message': 'Account activation link has been sent to your email address. '
                'Kindly take action before its expiration'
            },
            status=200,
        )
```

Token regeneration is almost like the registration page aside from the fact that the user ain't created but an inactive user with the provided email was retrieved from the database. The token was then generated and sent to the user.

It's time to register all these views now:

```python
# src/users/urls.py
from users.views import (
    confirm, # new
    login,
    logout,
    regenerate, # new
    register, # new
)

...

urlpatterns = [
    path('register/', register.RegisterView.as_view(), name='register'), # new
    path('register/confirm/<uidb64>/<token>/', confirm.ConfirmEmailView.as_view(), name='confirm'), # new
    path('regenerate-token/', regenerate.RegenerateTokenView.as_view(), name='regenerate'), # new
    ...
]
```

You can now test your endpoints with Postman or, if you use VS Code, [Thunder Client for VS Code][3].

Before we go, let's have another endpoint that retrieves the currently logged-in user:

```python
# src/users/views/current_user.py
import json
from typing import Any

from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpRequest, JsonResponse
from django.views import View

from users.models import UserProfile


class CurrentUserView(View, LoginRequiredMixin):
    def get(self, request: HttpRequest, **kwargs: dict[str, Any]) -> JsonResponse:
        """Get current user via session."""
        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
            )

        user_details = UserProfile.objects.filter(user=request.user).select_related('user').get()

        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)
```

It's a pretty simple view and it's our first synchronous view. I settled for that because `request.user` is synchronous. In the finished code on GitHub, I used `sync_to_async` decorator for the view. You can now add this view to your `users/urls.y` file.

That's it for this article. In the next one, we'll talk about updating user data and changing users' passwords. 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://dev.to/sirneij/series/22690 "Secure and performant full-stack authentication system using rust (actix-web) and sveltekit Series' Articles"
[2]: https://docs.djangoproject.com/en/4.2/topics/auth/customizing/ "Customizing authentication in Django"
[3]: https://www.thunderclient.com/ "Hand-crafted lightweight Rest Client for Testing APIs"