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:
Sirneij / django-auth-backend
Django session-based authentication system with SvelteKit frontend
django-auth-backend
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
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
…
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
virtualenv
was created using this command:
~/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
— 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
Now change the directory to src
and create a Django project:
~(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:
~(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
:
# 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. 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'
...
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."""
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()
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',)
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')),
]
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 = [
]
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()
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)
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)
...
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)
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'),
]
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)