DEV Community

Cover image for Authentication system using Python (Django) and SvelteKit - User Registration
John Owolabi Idogun
John Owolabi Idogun

Posted on

Authentication system using Python (Django) and SvelteKit - User Registration

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

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

We are overriding the built-in UserAdmin, providing our custom fields in the important subsections. I recommend reading through the documentation. 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:

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:

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

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:

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

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:

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

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:

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

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:

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

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:

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

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:

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

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

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

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

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 ☕. 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)