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:
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.
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:
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:
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:
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),},}ifos.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=TrueSESSION_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
ifDEBUG: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=TrueEMAIL_USE_SSL=FalseEMAIL_PORT=587EMAIL_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=NoneAWS_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:
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:
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:
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
importuuidfromtypingimportAnyfromdjango.contrib.auth.modelsimportAbstractUser,BaseUserManagerfromdjango.dbimportmodelsfromdjango.db.models.signalsimportpost_savefromdjango.dispatchimportreceiverclassUserManager(BaseUserManager):# type:ignore
"""UserManager class."""defcreate_user(self,email:str,password:str,**extra_fields:dict[str,Any])->AbstractUser:"""Create and save a User with the given email and password."""ifnotemail:raiseValueError('The Email must be set')email=self.normalize_email(email)user=self.model(email=email,**extra_fields)user.set_password(password)user.save()returnuserdefcreate_superuser(self,email:str,password:str,**extra_fields:dict[str,Any])->AbstractUser:"""Create and return a `User` with superuser (admin) permissions."""ifpasswordisNone:raiseTypeError('Superusers must have a password.')user=self.create_user(email,password)user.is_superuser=Trueuser.is_staff=Trueuser.is_active=Trueuser.save()returnuserclassUser(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.emailifself.email!=''elseself.get_full_name()returnf'{self.id}{string}'classUserProfile(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)classMeta:db_table='user_profile'def__str__(self)->str:"""Return a string representation of this UserProfile."""string=self.user.emailifself.user.email!=''elseself.user.get_full_name()returnf'<UserProfile {self.id}{string}>'@receiver(post_save,sender=User)defupdate_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."""ifcreated: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
importjsonfromtypingimportAnyfromasgiref.syncimportsync_to_asyncfromdjango.contrib.authimportauthenticate,loginfromdjango.httpimportHttpRequest,JsonResponsefromdjango.utils.decoratorsimportmethod_decoratorfromdjango.viewsimportViewfromdjango.views.decorators.csrfimportcsrf_exemptfromusers.modelsimportUserProfile@method_decorator(csrf_exempt,name='dispatch')classLoginPageView(View):asyncdefpost(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')ifemailisNoneorpasswordisNone:returnJsonResponse({'error':'Please provide email and password'},status=400)user=awaitsync_to_async(authenticate)(email=email,password=password,)ifuserisNone:returnJsonResponse({'error':'Email and password do not match'},status=400)awaitsync_to_async(login)(request,user)user_details=awaitUserProfile.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.urlifuser_details.user.thumbnailelseNone,'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)ifuser_details.birth_dateelseNone,},}response_data=json.loads(json.dumps(res_data))returnJsonResponse(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:
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
fromtypingimportAnyfromasgiref.syncimportsync_to_asyncfromdjango.contrib.authimportlogoutfromdjango.contrib.auth.mixinsimportLoginRequiredMixinfromdjango.httpimportHttpRequest,JsonResponsefromdjango.utils.decoratorsimportmethod_decoratorfromdjango.viewsimportViewfromdjango.views.decorators.csrfimportcsrf_exempt@method_decorator(csrf_exempt,name='dispatch')classLogoutView(View,LoginRequiredMixin):asyncdefpost(self,request:HttpRequest,**kwargs:dict[str,Any])->JsonResponse:"""Handle user logouts."""awaitsync_to_async(logout)(request)returnJsonResponse({'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.