User Authentication with Django REST Framework and JSON Web Tokens

Photo by Matthew Brodeur on Unsplash

Over the past couple of months, I have been building a web application on my spare time. While I would like to keep the details of my app under wraps (for now), I did want to share some of the struggles I have overcome while working on this app; specifically when it comes to user management and authentication with JSON web tokens (JWT).

Tech Stack

Before I dive deep into the code, I wanted to give some background information on the technology that I will be using. On the backend, I chose Django and the Django Rest Framework for developing a RESTful API that would be at the core of my app. On the frontend, I have decided to use Reactjs to build out my SPA to make calls to the backend. I will be using Docker for containerization, CircleCI for building out my CI/CD pipeline, and AWS as the infrastructure service. Finally, I will be using Postman to run tests and monitor the API as I go. This walkthrough moves very quickly and assumes some familiarity with Python, Django, and Docker.

Requirements

Before starting, you should have the following python dependencies installed:

Django==2.1 
django-rest-framework==0.1.0
djangorestframework==3.8.2
gunicorn==19.9.0 # Our WSGI server
psycopg2==2.7.5 # Only if using Postgresql
PyJWT==1.6.4
pytz==2018.5

Since I am using Docker, you can get up and running with the following Dockerfile:

FROM python:3.6-alpine

MAINTAINER Sebastian Ojeda <sebastian@oddjobbox.com>

WORKDIR /app

COPY requirements.txt .

RUN apk add --no-cache --virtual .build-deps \
build-base postgresql-dev jpeg-dev zlib-dev \
&& pip install -r requirements.txt \
&& find /usr/local \
\( -type d -a -name test -o -name tests \) \
-o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) \
-exec rm -rf '{}' + \
&& runDeps="$( \
scanelf --needed --nobanner --recursive /usr/local \
| awk '{ gsub(/,/, "\nso:", $2); print "so:" $2 }' \
| sort -u \
| xargs -r apk info --installed \
| sort -u \
)" \
&& apk add --virtual .rundeps $runDeps \
&& apk del .build-deps

COPY . .

CMD ["python3", "manage.py", "runserver", "0:8000"]

I am still working on making the file size of this image smaller (please let me know in the comments if you have a solution for making this image any smaller). In the meantime, this will suffice. Moving on!

Creating a User model

The Django user model is pretty straight forward. We will be inheriting from the AbstractBaseUser and the PermissionsMixin classes to create our model.

import jwt

from datetime import datetime
from datetime import timedelta

from django.conf import settings
from django.db import models
from django.core import validators
from django.contrib.auth.models import AbstractBaseUser
from django.contrib.auth.models import PermissionsMixin

class User(AbstractBaseUser, PermissionsMixin):
"""
Defines our custom user class.
Username, email and password are required.
"""

username = models.CharField(db_index=True, max_length=255, unique=True)

email = models.EmailField(
validators=[validators.validate_email],
unique=True,
blank=False
)

is_staff = models.BooleanField(default=False)

is_active = models.BooleanField(default=True)

# The `USERNAME_FIELD` property tells us which field we will use to log in.
USERNAME_FIELD = 'email'

REQUIRED_FIELDS = ('username',)

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

def __str__(self):
"""
Returns a string representation of this `User`.
This string is used when a `User` is printed in the console.
"""
return self.username

@property
def token(self):
"""
Allows us to get a user's token by calling `user.token` instead of
`user.generate_jwt_token().

The `@property` decorator above makes this possible. `token` is called
a "dynamic property".
"""
return self._generate_jwt_token()

def get_full_name(self):
"""
This method is required by Django for things like handling emails.
Typically this would be the user's first and last name. Since we do
not store the user's real name, we return their username instead.
"""
return self.username

def get_short_name(self):
"""
This method is required by Django for things like handling emails.
Typically, this would be the user's first name. Since we do not store
the user's real name, we return their username instead.
"""
return self.username

def _generate_jwt_token(self):
"""
Generates a JSON Web Token that stores this user's ID and has an expiry
date set to 60 days into the future.
"""
dt = datetime.now() + timedelta(days=60)

token = jwt.encode({
'id': self.pk,
'exp': int(dt.strftime('%s'))
}, settings.SECRET_KEY, algorithm='HS256')

return token.decode('utf-8')

As you can see, the token is created dynamically with the @property decorator with an expiry of 60 days. The UserManager that will be called when creating a new user is as follows:

from django.contrib.auth.models import BaseUserManager

class UserManager(BaseUserManager):
"""
Django requires that custom users define their own Manager class. By
inheriting from `BaseUserManager`, we get a lot of the same code used by
Django to create a `User`.

All we have to do is override the `create_user` function which we will use
to create `User` objects.
"""

def _create_user(self, username, email, password=None, **extra_fields):
if not username:
raise ValueError('The given username must be set')

if not email:
raise ValueError('The given email must be set')

email = self.normalize_email(email)
user = self.model(username=username, email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)

return user

def create_user(self, username, email, password=None, **extra_fields):
"""
Create and return a `User` with an email, username and password.
"""
extra_fields.setdefault('is_staff', False)
extra_fields.setdefault('is_superuser', False)

return self._create_user(username, email, password, **extra_fields)

def create_superuser(self, username, email, password, **extra_fields):
"""
Create and return a `User` with superuser (admin) permissions.
"""
extra_fields.setdefault('is_staff', True)
extra_fields.setdefault('is_superuser', True)

if extra_fields.get('is_staff') is not True:
raise ValueError('Superuser must have is_staff=True.')

if extra_fields.get('is_superuser') is not True:
raise ValueError('Superuser must have is_superuser=True.')

return self._create_user(username, email, password, **extra_fields)

There is a lot of code that was dumped out just now but my goal is to get you up and running as soon as possible. I recommend reading through the code line by line to make sure you understand what is going on (this is generally a good idea whenever you are copying code from the internet!). The User class and UserManager are all you need to create a custom user in Django! Just don’t forget to let Django know that these models exist by declaring your app in the settings.py file:

INSTALLED_APPS = [
...
'rest_framework',
'authentication', # My'authentication` app
...
]

AUTH_USER_MODEL = 'authentication.User'

Authentication Backend

By default, Django does not know how to authenticate your JWTs. To fix this, we must the create the following backends.py file:

import jwt

from django.conf import settings

from rest_framework import authentication, exceptions

from .models import User


class JWTAuthentication(authentication.BaseAuthentication):
authentication_header_prefix = 'Bearer'

def authenticate(self, request):
"""
The `authenticate` method is called on every request regardless of
whether the endpoint requires authentication.

`authenticate` has two possible return values:

1) `None` - We return `None` if we do not wish to authenticate. Usually
this means we know authentication will fail. An example of
this is when the request does not include a token in the
headers.

2) `(user, token)` - We return a user/token combination when
authentication is successful.

If neither case is met, that means there's an error
and we do not return anything.
We simple raise the `AuthenticationFailed`
exception and let Django REST Framework
handle the rest.
"""
request.user = None

# `auth_header` should be an array with two elements: 1) the name of
# the authentication header (in this case, "Token") and 2) the JWT
# that we should authenticate against.
auth_header = authentication.get_authorization_header(request).split()
auth_header_prefix = self.authentication_header_prefix.lower()

if not auth_header:
return None

if len(auth_header) == 1:
# Invalid token header. No credentials provided. Do not attempt to
# authenticate.
return None

elif len(auth_header) > 2:
# Invalid token header. The Token string should not contain spaces.
# Do not attempt to authenticate.
return None

# The JWT library we're using can't handle the `byte` type, which is
# commonly used by standard libraries in Python 3. To get around this,
# we simply have to decode `prefix` and `token`. This does not make for
# clean code, but it is a good decision because we would get an error
# if we didn't decode these values.
prefix = auth_header[0].decode('utf-8')
token = auth_header[1].decode('utf-8')

if prefix.lower() != auth_header_prefix:
# The auth header prefix is not what we expected. Do not attempt to
# authenticate.
return None

# By now, we are sure there is a *chance* that authentication will
# succeed. We delegate the actual credentials authentication to the
# method below.
return self._authenticate_credentials(request, token)

def _authenticate_credentials(self, request, token):
"""
Try to authenticate the given credentials. If authentication is
successful, return the user and token. If not, throw an error.
"""
try:
payload = jwt.decode(token, settings.SECRET_KEY)
except:
msg = 'Invalid authentication. Could not decode token.'
raise exceptions.AuthenticationFailed(msg)

try:
user = User.objects.get(pk=payload['id'])
except User.DoesNotExist:
msg = 'No user matching this token was found.'
raise exceptions.AuthenticationFailed(msg)

if not user.is_active:
msg = 'This user has been deactivated.'
raise exceptions.AuthenticationFailed(msg)

return (user, token)

Again, this is a lot of code being thrown out, but I like to think that it is fairly straight forward if you have some experience with Python and Django.

We must also remember to update our settings.py file to tell Django where to find our custom authentication backend:

...
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
'DEFAULT_AUTHENTICATION_CLASSES': (
'authentication.backends.JWTAuthentication',
)
}
...

By now you have created a custom User model and UserManager model, and created a custom JWTAuthentication class to authenticate your user tokens. The last piece missing is to setup your user views for DRF to handle.

DRF Serializers

There are a couple of views that need to be serialized to finally get up and running. The first one is the RegistrationSerializer

from rest_framework import serializers
from .models import User

class RegistrationSerializer(serializers.ModelSerializer):
"""
Creates a new user.
Email, username, and password are required.
Returns a JSON web token.
"""

# The password must be validated and should not be read by the client
password = serializers.CharField(
max_length=128,
min_length=8,
write_only=True,
)

# The client should not be able to send a token along with a registration
# request. Making `token` read-only handles that for us.
token = serializers.CharField(max_length=255, read_only=True)

class Meta:
model = User
fields = ('email', 'username', 'password', 'token',)

def create(self, validated_data):
return User.objects.create_user(**validated_data)

This serializer will receive a username, email, and password and will return a user token if authentication is successful. Next we need a way of logging existing user in. We will create a LoginSerializer for this:

class LoginSerializer(serializers.Serializer):
"""
Authenticates an existing user.
Email and password are required.
Returns a JSON web token.
"""
email = serializers.EmailField(write_only=True)
password = serializers.CharField(max_length=128, write_only=True)

# Ignore these fields if they are included in the request.
username = serializers.CharField(max_length=255, read_only=True)
token = serializers.CharField(max_length=255, read_only=True)

def validate(self, data):
"""
Validates user data.
"""
email = data.get('email', None)
password = data.get('password', None)

if email is None:
raise serializers.ValidationError(
'An email address is required to log in.'
)

if password is None:
raise serializers.ValidationError(
'A password is required to log in.'
)

user = authenticate(username=email, password=password)

if user is None:
raise serializers.ValidationError(
'A user with this email and password was not found.'
)

if not user.is_active:
raise serializers.ValidationError(
'This user has been deactivated.'
)

return {
'token': user.token,
}

DRF Views

The login process will also return a user token, but only if the user has already been created. With these two serializers in place, we can move on to our view.py file. We simply need to include a view for registering and for logging in.

from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView

from .models import User
from .serializers import LoginSerializer
from .serializers import RegistrationSerializer

class RegistrationAPIView(APIView):
"""
Registers a new user.
"""
permission_classes = [AllowAny]
serializer_class = RegistrationSerializer

def post(self, request):
"""
Creates a new User object.
Username, email, and password are required.
Returns a JSON web token.
"""
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()

return Response(
{
'token': serializer.data.get('token', None),
},
status=status.HTTP_201_CREATED,
)


class LoginAPIView(APIView):
"""
Logs in an existing user.
"""
permission_classes = [AllowAny]
serializer_class = LoginSerializer

def post(self, request):
"""
Checks is user exists.
Email and password are required.
Returns a JSON web token.
"""
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)

return Response(serializer.data, status=status.HTTP_200_OK)

The final step is to set up our urls.py file to map our views to a url.

from django.urls import re_path, include

from .views import RegistrationAPIView
from .views import LoginAPIView

urlpatterns = [
re_path(r'^registration/?$', RegistrationAPIView.as_view(), name='user_registration'),
re_path(r'^login/?$', LoginAPIView.as_view(), name='user_login'),
]

Summary

With all these files created, we are now able to register and log in users using our custom Django models and successfully authenticate our users with JSON Web Tokens. While most of this information has been dumped on this page, I hope it has been helpful to those looking to do something similar. Keep hacking, friends!


Originally published at sebastianojeda.io on October 4, 2018.