DEV Community

Cover image for Authentication system using Python (Django) and SvelteKit - Setup, Login & Logout
John Owolabi Idogun
John Owolabi Idogun

Posted on

Authentication system using Python (Django) and SvelteKit - Setup, Login & Logout

Introduction

Following the completion of the previous series on using Actix web & SvelteKit to build a performant, secure, resilient and reliable authentication system, we will be replicating such a system, in its exact form, using Python (Django). Argon2 will be used for password hashing for strong security, Redis will help save our session so that it will be faster to retrieve compared with Django default session storage, AWS S3 will house our file uploads and static files (for Django admin page - this is optional), emails will be sent asynchronously, password update or change will be custom-made and a host of other features. We'll enforce types and good code styles using Python's rich ecosystem with tools like mypy, pylint, black, isort, prospector, and bandit. 100% automated test coverage will be enforced and along the way, we will know about pytest and its ecosystem, handling file creation in test, sending properly encoded FormData in test and a host of others. We will mostly use Django's async views to write our views. No other REST API framework will be used. Let's get started.

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: Setup a brand new Django project

Of course, we need a new Django project. But before then, we should create a virtual environment for it to avoid conflicting versions of packages in our machines. You can use anything tool of your choosing but I will go with the good old virtualenv and pip. Create your project's directory, a virtual environment, and another folder called src where our project lives. You should also create a tests folder in the same directory as src. Your structure should look like this:

├── virtualenv
├── src
└── tests
Enter fullscreen mode Exit fullscreen mode

virtualenv was created using this command:

~/django-auth-backend$ virtualenv -p python3.11 virtualenv
Enter fullscreen mode Exit fullscreen mode

We opted to use the latest Python version. You can then activate it. Activation depends on your OS.

Next, install django, argon2 — password hashing, celery — asynchronous tasks such as sending emails, psycopg2-binary — Python's PostgreSQL database adapter, Pillow — mandatory for image uploads, django-redis — for better interface with Redis, boto3 — AWS S3 library, and django-storages — for easy configurations of our storages:

~(virtualenv)/django-auth-backend$ pip install django 'django[argon2]' celery psycopg2-binary pillow boto3 django-redis django-storages
Enter fullscreen mode Exit fullscreen mode

Now change the directory to src and create a Django project:

~(virtualenv)/django-auth-backend$ cd src && django-admin startproject django_auth .
Enter fullscreen mode Exit fullscreen mode

Notice the dot (.) at the end. It tells Django to create the project in the current directory. You can now see a file, manage.py, in your current directory. Use it to start an application called users and in the newly created app, create a urls.py file:

~(virtualenv)/django-auth-backend/src$ python manage.py startapp users

~(virtualenv)/django-auth-backend/src$ touch users/urls.py
Enter fullscreen mode Exit fullscreen mode

It's time to customise our project's settings.py. Open the entire project in your favourite text editor and let's edit django_auth/settings.py:

# src/django_auth/settings.py
...
INSTALLED_APPS = [
    ...
    # Local app
    'users.apps.UsersConfig',
]
...
# Password hashes
PASSWORD_HASHERS = [
    'django.contrib.auth.hashers.Argon2PasswordHasher',
    'django.contrib.auth.hashers.PBKDF2PasswordHasher',
    'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
    'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
    'django.contrib.auth.hashers.ScryptPasswordHasher',
]
...
TEMPLATES = [
    {
        ...
        'DIRS': [BASE_DIR / 'templates'],
        ...
    },
]
...
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql_psycopg2',
        'URL': config(
            'DATABASE_URL',
            default='postgres://quickcheck:password@localhost:5432/django_auth_backend_db',
        ),
        'NAME': config('DB_NAME', default='django_auth_backend_db'),
        'USER': config('DB_USER', default='quickcheck'),
        'PASSWORD': config('DB_PASSWORD', default='password'),
        'HOST': config('DB_HOST', default='localhost'),
        'PORT': config('DB_PORT', default=5432, cast=int),
    },
}
if os.environ.get('GITHUB_WORKFLOW'):
    DATABASES['default']['ENGINE'] = 'django.db.backends.postgresql_psycopg2'
    DATABASES['default']['NAME'] = 'github_actions'
    DATABASES['default']['USER'] = config('DB_USER', default='postgres')
    DATABASES['default']['PASSWORD'] = config('DB_PASSWORD', default='postgres')
    DATABASES['default']['HOST'] = '127.0.0.1'
    DATABASES['default']['PORT'] = 5432

# Session
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
SESSION_CACHE_ALIAS = 'default'
CSRF_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_HTTPONLY = True
SESSION_COOKIE_HTTPONLY = True

# User model
AUTH_USER_MODEL = 'users.User'

# Celery
CELERY_BROKER_URL = config('REDIS_URL', default='amqp://localhost')
CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'

# Enail configuration
if DEBUG:
    EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
else:
    EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
    EMAIL_HOST = 'smtp.gmail.com'
    EMAIL_USE_TLS = True
    EMAIL_USE_SSL = False
    EMAIL_PORT = 587
    EMAIL_HOST_USER = config('APP_EMAIL__HOST_USER', default='')
    EMAIL_HOST_PASSWORD = config('APP_EMAIL__HOST_USER_PASSWORD', default='')
    EMAIL_FROM = 'Authentication System - Django Backend'

DEFAULT_FROM_EMAIL = config('APP_EMAIL__HOST_USER', default='')

ADMINS = (('Admin', config('APP_EMAIL__HOST_USER', default='')),)

# For token generation
PASSWORD_RESET_TIMEOUT = config('TOKEN_EXPIRATION', default=600, cast=int)

# AWS

# aws settings
AWS_ACCESS_KEY_ID = config('AWS_ACCESS_KEY_ID', default='')
AWS_SECRET_ACCESS_KEY = config('AWS_SECRET_ACCESS_KEY', default='')
AWS_STORAGE_BUCKET_NAME = config('AWS_S3_BUCKET_NAME', default='')
AWS_STORAGE_REGION = config('AWS_REGION', default='')
AWS_DEFAULT_ACL = None
AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.{AWS_STORAGE_REGION}.amazonaws.com'
AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'}
# Media
PUBLIC_MEDIA_LOCATION = 'media/users/django-auth'
MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{PUBLIC_MEDIA_LOCATION}/'
# Static
# s3 static settings
STATIC_LOCATION = 'django-auth/static'
STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/'

STORAGES = {
    'default': {
        'BACKEND': 'django_auth.storage_backends.PublicMediaStorage',
    },
    'staticfiles': {
        'BACKEND': 'django_auth.storage_backends.StaticStorage',
    },
}
Enter fullscreen mode Exit fullscreen mode

That's a lot! Let's go through it. We first added our new application to the INSTALLED_APPS section. Then we listed the hashers we want for our passwords. django.contrib.auth.hashers.Argon2PasswordHasher tops the list since that's what we want. Then we told TEMPLATES to pick up any files found in the templates directory located at the root of the project. We then disposed of the SQLite database configuration that came with the Django project starter command. We will be using PostgreSQL. Notice the config we used. It was imported from decouple, an awesome library for managing parameters in .env or ini files. Since I will be using GitHub Actions for automated testing, static analysis and linting checks and deployment to Vercel, I also made it possible for the actions to provide the database credentials on which they will run. Next was the configuration of our cache system. It uses Redis and this cache was extended to our session storage. For the session, we used the same configurations as our actix application.

Also, since we'll be extending Django's default auth model, we needed to tell Django the name of the extended model, hence this line:

...
AUTH_USER_MODEL = 'users.User'
...
Enter fullscreen mode Exit fullscreen mode

We also configured Celery and again, we'll be using Redis as its broker. We then configured our email settings. During development (when DEBUG is True), we want to use Django's console as our Email backend. Which means the sent messages will appear in our terminal. AWS settings were then set next and we will be using it for both media and static file storage. Notice the STORAGES section. It used to be called DEFAULT_FILE_STORAGE and STATICFILES_STORAGE but are now deprecated. The engines we used there are custom-made and their definitions are found in django_auth/storage_backends.py:

# src/django_auth/storage_backends.py
from typing import Any

from storages.backends.s3boto3 import S3Boto3Storage


class StaticStorage(S3Boto3Storage):
    location = 'django-auth/static'
    default_acl = 'public-read'

    def get_accessed_time(self, name: str) -> Any:
        """Override method."""

    def get_created_time(self, name: str) -> Any:
        """Override method."""

    def path(self, name: str) -> Any:
        """Override method."""


class PublicMediaStorage(S3Boto3Storage):
    location = 'media/users/django-auth'
    default_acl = 'public-read'
    file_overwrite = False

    def get_accessed_time(self, name: str) -> Any:
        """Override method."""

    def get_created_time(self, name: str) -> Any:
        """Override method."""

    def path(self, name: str) -> Any:
        """Override method."""
Enter fullscreen mode Exit fullscreen mode

Just modifying some defaults. You can read more here. We also need to configure our Celery installation. Create a new file, called celery.py in the django_auth folder:

# src/django_auth/celery.py
import os

from celery import Celery

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_auth.settings')

app = Celery('django_auth')
app.config_from_object('django.conf:settings', namespace='CELERY')

app.autodiscover_tasks()
Enter fullscreen mode Exit fullscreen mode

It was lifted from this guide and modified accordingly. To finish off with celery-related config, open up django_auth/__init__.py:

# src/django_auth/__init__.py
from .celery import app as celery_app

__all__ = ('celery_app',)
Enter fullscreen mode Exit fullscreen mode

This helps load the celery application as soon as Django loads.

Before finishing this section, let's link the users/urls.py file we created before to our project's urls.py file:

# src/django_auth/urls.py
...
from django.urls import include, path

urlpatterns = [
    ...
    path('users/', include('users.urls', namespace='users')),
]
Enter fullscreen mode Exit fullscreen mode

We simply prepend all URLs in users/urls.py with /users/. The namespace='users' will help us to easily construct URLs during testing using Django's reverse. To wrap up, let's just put something in users/urls.py:

# src/users/urls.py
from django.urls import path

app_name = 'users'

urlpatterns = [

]
Enter fullscreen mode Exit fullscreen mode

This app_name = 'users' is mandatory if your URL inclusion has the namespace property.

Step 2: User models, login and logout views

Now to the implementation proper, let's create our User model:

# src/users/models.py
import uuid
from typing import Any

from django.contrib.auth.models import AbstractUser, BaseUserManager
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver


class UserManager(BaseUserManager):  # type:ignore
    """UserManager class."""

    def create_user(self, email: str, password: str, **extra_fields: dict[str, Any]) -> AbstractUser:
        """Create and save a User with the given email and password."""
        if not email:
            raise ValueError('The Email must be set')
        email = self.normalize_email(email)
        user = self.model(email=email, **extra_fields)
        user.set_password(password)
        user.save()
        return user

    def create_superuser(self, email: str, password: str, **extra_fields: dict[str, Any]) -> AbstractUser:
        """Create and return a `User` with superuser (admin) permissions."""
        if password is None:
            raise TypeError('Superusers must have a password.')

        user = self.create_user(email, password)
        user.is_superuser = True
        user.is_staff = True
        user.is_active = True
        user.save()

        return user


class User(AbstractUser):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    username = None  # type:ignore
    email = models.EmailField(db_index=True, unique=True)
    thumbnail = models.ImageField(upload_to='users/', null=True)

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []

    # Tells Django that the UserManager class defined above should manage
    # objects of this type.
    objects = UserManager()  # type:ignore

    def __str__(self) -> str:
        """Return a string representation of this User."""
        string = self.email if self.email != '' else self.get_full_name()
        return f'{self.id} {string}'


class UserProfile(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    phone_number = models.CharField(max_length=20, null=True)
    github_link = models.CharField(max_length=20000, null=True)
    birth_date = models.DateField(null=True)

    class Meta:
        db_table = 'user_profile'

    def __str__(self) -> str:
        """Return a string representation of this UserProfile."""
        string = self.user.email if self.user.email != '' else self.user.get_full_name()
        return f'<UserProfile {self.id} {string}>'


@receiver(post_save, sender=User)
def update_user_profile_signal(sender: Any, instance: User, created: bool, **kwargs: dict[str, Any]) -> None:
    """Create or update UserProfile model after each user gets created or updated."""
    if created:
        UserProfile.objects.create(user=instance)
    instance.userprofile.save()
Enter fullscreen mode Exit fullscreen mode

Since we need all the fields pre-added by Django apart from the username field and some additional fields, we subclassed AbstractUser. If you want to totally let go of Django's default model, subclass AbstractBaseUser instead. We used UUID as our id, ditched the username field, set and made the email field as the username's replacement, and then added the thumbnail field. This is exactly like what we did in the actix web auth system. We also subclassed the BaseUserManager to define some methods we will use to create normal and super users. This custom manager was then used as the default manager of our User model. Next is the UserProfile model which has a One-To-One relationship with the User model. The fields there are pretty basic and we could have just added them directly to the User but we are mirroring what we did in the previous series. A part to note is the update_user_profile_signal function. It is a signal that gets called every time a User gets created or updated. Normally, it should live in a signals.py file and then be imported in the ready method of the users/apps.py file class but I chose to leave it there for simplicity's sake.

Now to our views, we will split them into files. So let's create a views package inside the users application. Then, create login.py and logout.py in them. Let's populate them:

# src/users/views/login.py
import json
from typing import Any

from asgiref.sync import sync_to_async
from django.contrib.auth import authenticate, login
from django.http import HttpRequest, JsonResponse
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 LoginPageView(View):
    async def post(self, request: HttpRequest, **kwargs: dict[str, Any]) -> JsonResponse:
        """Handle user logins."""
        data = json.loads(request.body.decode('utf-8'))
        email = data.get('email')
        password = data.get('password')

        if email is None or password is None:
            return JsonResponse({'error': 'Please provide email and password'}, status=400)

        user = await sync_to_async(authenticate)(
            email=email,
            password=password,
        )

        if user is None:
            return JsonResponse({'error': 'Email and password do not match'}, status=400)

        await sync_to_async(login)(request, user)

        user_details = await UserProfile.objects.filter(user=user).select_related('user').aget()

        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

First, we decorated Class-Based View (CBV) with the csrf_exempt so that we could access it without supplying CSRF token. This view is async so we must ensure async codes in it. We extracted the JSON data from the request body and validated them. Then, we use Django's authenticate method to authenticate and retrieve such a user. If we ain't in an async view, that line would be:

...
user = authenticate(email=email, password=password)
...
Enter fullscreen mode Exit fullscreen mode

If a no user is returned, we know the email/password combination was not correct and an appropriate error was returned. Otherwise, we logged the user in using the async syntax talked about above. Using the user, we retrieved the user profile, joining the User model with it using select_related. Notice the use of aget. It's the asynchronous version of get. We then build the return data and return such a user with cookies present in the response. The was collected in the front end and stored in the browser cookie ensuring that it's HttpOnly and secure.

Next is the logout.py:

# src/users/views/login.py
from typing import Any

from asgiref.sync import sync_to_async
from django.contrib.auth import logout
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpRequest, JsonResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt


@method_decorator(csrf_exempt, name='dispatch')
class LogoutView(View, LoginRequiredMixin):
    async def post(self, request: HttpRequest, **kwargs: dict[str, Any]) -> JsonResponse:
        """Handle user logouts."""
        await sync_to_async(logout)(request)
        return JsonResponse({'message': 'You have successfully logged out'}, status=200)
Enter fullscreen mode Exit fullscreen mode

The only thing we did was log the user out via Django's logout method. With that, the user's session is destroyed.

Let's include these views in our urls.py file:

...
from users.views import login, logout
...
urlpatterns = [
    path('login/', login.LoginPageView.as_view(), name='login'),
    path('logout/', logout.LogoutView.as_view(), name='logout'),
]
Enter fullscreen mode Exit fullscreen mode

With that, we're done with logging in/out users.

We'll draw the curtains here. In the next article, we will discuss registering users. See y'all.

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)