Authentication system using Python (Django) and SvelteKit - Setup, Login & Logout
Tags: githubhack23, python, django, webdev
Published: 2023-05-17 19:31:46

## Introduction

Following the completion of the previous [series][1] 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][2] 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][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: 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:

```shell
├── virtualenv
├── src
└── tests
```

`virtualenv` was created using this command:

```shell
~/django-auth-backend$ virtualenv -p python3.11 virtualenv
```

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

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

```shell
~(virtualenv)/django-auth-backend$ pip install django 'django[argon2]' celery psycopg2-binary pillow boto3 django-redis django-storages
```

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

```shell
~(virtualenv)/django-auth-backend$ cd src && django-admin startproject django_auth .
```

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:

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

~(virtualenv)/django-auth-backend/src$ touch users/urls.py
```

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

```python
# 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',
    },
}
```

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][3]. 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][4], 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:

```python
...
AUTH_USER_MODEL = 'users.User'
...
```

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][5]. The engines we used there are custom-made and their definitions are found in `django_auth/storage_backends.py`:

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

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

```python
# 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()
```

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

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

__all__ = ('celery_app',)
```

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:

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

urlpatterns = [
    ...
    path('users/', include('users.urls', namespace='users')),
]
```

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][8]. To wrap up, let's just put something in  `users/urls.py`:

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

app_name = 'users'

urlpatterns = [

]
```

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:

```python
# 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()
```

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][1]. A part to note is the `update_user_profile_signal` function. It is a [signal][9] 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:

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

First, we decorated Class-Based View (CBV) with the `csrf_exempt` so that we could access it without supplying [CSRF][10] 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:

```python
...
user = authenticate(email=email, password=password)
...
```

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

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

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:

```python
...
from users.views import login, logout
...
urlpatterns = [
    path('login/', login.LoginPageView.as_view(), name='login'),
    path('logout/', logout.LogoutView.as_view(), name='logout'),
]
```

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 ☕](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://en.wikipedia.org/wiki/Argon2 "Argon2"
[3]: https://docs.djangoproject.com/en/4.2/topics/auth/passwords/#using-argon2-with-django "Using Argon2 with Django"
[4]: https://pypi.org/project/python-decouple/ "python-decouple"
[5]: https://docs.djangoproject.com/en/4.2/releases/4.2/#custom-file-storages "Custom file storages"
[6]: https://testdriven.io/blog/storing-django-static-and-media-files-on-amazon-s3/ "Storing Django Static and Media Files on Amazon S3"
[7]: https://docs.celeryq.dev/en/stable/django/first-steps-with-django.html#using-celery-with-django "Using Celery with Django"
[8]: https://docs.djangoproject.com/en/4.2/ref/urlresolvers/#reverse "reverse()"
[9]: https://docs.djangoproject.com/en/4.2/topics/signals/#module-django.dispatch "Signals"
[10]: https://docs.djangoproject.com/en/4.2/ref/csrf/ "Cross-Site Request Forgery protection"