Demystifying Token-Based Authentication using Django REST Framework
Authentication is one of those things which have now been considered a rote and repetitive task when doing web development. Most applications you will ever develop almost always need to have some form of user authentication to allow users access the app’s functionality.
Generally speaking, all authentication you will do goes through these steps:
- Get the details sent in by the user (Parse JSON or use form data)
- Search for the user from the user’s table on the column identified with the unique field value passed in by the user (This is usually either username or e-mail address).
- Get the first record from the above query. (This is usually done because we assume there should be no duplicates on the unique field we’re searching on)
- Hash the password using the same algorithms to hash passwords at register phase (Done before saving the user object).
- Check if the password value matches the password field on the user’s table.
- If true, login user (Add to the session or generate a token). Redirect to the main app page.
- If false, return the user to the login page. Send error messages.
These must look pretty accurate to any developer who has done this before. Implementing authentication using DRF is done the same way. The approach, however, may differ from what you’re used to especially if you’re coming from a more expressive programming language.
The User Model #
Django provides a User
model and a number of methods to help with things like authentication and session management.
To ensure we get our hands as dirty as possible, however, we will be creating our own user model. Our model will be inheriting from the AbstractBaseUser
class available in django.contrib.auth.models
. We shall then add our extra features ontop of that.
Create a new app called authentication
. This app will handle everything that has to do with user authentication and management.
python manage.py createapp authentication
In authentication/models.py
, we start by creating the Account
model for storing user details and generally working with user objects.
from django.db import models
from django.contrib.auth.models import AbstractBaseUser
class Account(AbstractBaseUser):
username = models.CharField(unique=True, max_length=50)
email = models.EmailField(unique=True)
firstname = models.CharField(max_length=100, blank=True)
lastname = models.CharField(max_length=100, blank=True)
date_created = models.DateTimeField(auto_now_add=True)
date_modified = models.DateTimeField(auto_now=True)
is_admin = models.BooleanField(default=False)
objects = AccountManager()
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['username']
What we’ve done here is to create a model to handle all our user management. I’ll summarize what happened in the code snippet above:
- We specified a number of fields to describe our users. These are what you will usually have in any users table. These fields are in addition to the
password
and thelast_login
field available in the base class by default. - We set the
USERNAME_FIELD
variable to be whatever field we choose to use as username field (when performing the login operation). This will be the default lookup field when searching for users in the users table and hence, should be unique. We have chosen to use theemail
field for this in our example. - The
REQUIRED_FIELDS
list contains all the fields that are required. By default theUSERNAME_FIELD
and password field are automatically required.
As you can see, we linked the objects
parameter of this model to AccountManager
. This Manager class will have methods such as create_user()
and create_superuser()
and we shall be overriding the logic of these methods.
We will now add the code for the AccountManager
class above the Account
model class (This ensures that the model class can find the manager class).
from django.contrib.auth.models import BaseUserManager
class AccountManager(BaseUserManager):
def create_user(self, email, password=None, **kwargs):
# Ensure that an email address is set
if not email:
raise ValueError('Users must have a valid e-mail address')
# Ensure that a username is set
if not kwargs.get('username'):
raise ValueError('Users must have a valid username')
account = self.model(
email=self.normalize_email(email),
username=kwargs.get('username'),
firstname=kwargs.get('firstname', None),
lastname=kwargs.get('lastname', None),
)
account.set_password(password)
account.save()
return account
def create_superuser(self, email, password=None, **kwargs):
account = self.create_user(email, password, kwargs)
account.is_admin = True
account.save()
return account
As you can see above, the AccountManager
class inherits from the BaseUserManager
class. We implemented two major methods we shall be using later in our code:
-
create_user()
- Specifies logic that we want to run when creating a user. An important thing to note here is that we have indicated that we want the password set using theset_password()
method. This method is in theAbstractBaseUser
class and works by performing one-way hashing on the password value supplied before storing it. You can easily override this behaviour within yourAccount
class. -
create_superuser()
- This is very similar to thecreate_user()
method with the only addition being setting theis_admin
property toTrue
.
We’re making progress!
To ensure Django knows that we want to use a model different from the default User
model, we will add this line to settings.py
AUTH_USER_MODEL = 'authentication.Account'
This way, especially for the purpose of our token generation view, we are able to tell Django to use our model whenever anything to do with authentication is required.
A lot of Django can be magical at times and like many other modern frameworks relies on configuration. In our case, by configuring the
AUTH_USER_MODEL
,USERNAME_FIELD
andset_password()
logic, Django is able to use this information to perform authenticate and login operations using the right database table.
Authenticating the User #
We will be making use of Django-REST Framework JWT, a Python module that adds JWT authentication support for DRF apps. Using this package, we can easily implement JWT Based Authentication for app without doing so much.
A lot has already been done for us and with little configuration, we are up and running.
Run the following to get the DRF JWT installed:
pip install djangorestframework-jwt
After installing the package, the next thing to do is to add the login route to our urls.py
in the authentication app.
from django.conf.urls import url
from rest_framework_jwt.views import obtain_jwt_token
urlpatterns = [
url(r'^login/', obtain_jwt_token),
]
The obtain_jwt_token
view provided by DRF JWT handles authenticating the user and sending us a token if the user is properly logged. The token is returned in the following format:
{
token: "SOME_TOKEN_STRING"
}
We can specify different configuration parameters for our Tokens and how they are generated in the settings.py
file. Some options we can configure are shown below:
# Default JWT preferences
JWT_AUTH = {
'JWT_ENCODE_HANDLER':
'rest_framework_jwt.utils.jwt_encode_handler',
'JWT_DECODE_HANDLER':
'rest_framework_jwt.utils.jwt_decode_handler',
'JWT_PAYLOAD_HANDLER':
'rest_framework_jwt.utils.jwt_payload_handler',
'JWT_PAYLOAD_GET_USER_ID_HANDLER':
'rest_framework_jwt.utils.jwt_get_user_id_from_payload_handler',
'JWT_RESPONSE_PAYLOAD_HANDLER':
'rest_framework_jwt.utils.jwt_response_payload_handler',
'JWT_SECRET_KEY': SECRET_KEY,
'JWT_ALGORITHM': 'HS256',
'JWT_VERIFY': True,
'JWT_VERIFY_EXPIRATION': True,
'JWT_LEEWAY': 0,
'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=300),
'JWT_AUDIENCE': None,
'JWT_ISSUER': None,
'JWT_ALLOW_REFRESH': False,
'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=7),
'JWT_AUTH_HEADER_PREFIX': 'JWT',
}
The settings shown are the default provided by the package. We can change things like Token Expiration date, Secret Key or Encryption algorithm using these options. See more details about the different options in the docs.
We will need to link this with the main application urls.py
. To do this, we include it with a parent route pattern:
from django.conf.urls import include, url
from django.contrib import admin
urlpatterns = [
url(r'^admin/', include(admin.site.urls)),
url(r'^api/v1/auth/', include('authentication.urls')),
]
Doing this, all the URLs in authentication.urls
will be prefixed with /api/v1/auth
. Hence, the login route we created will be accessed using /api/v1/auth/login
.
That’s all for the login part of things
Registering the User #
Registering a user is also pretty straightforward. We will need to create the URL and then the view function to handle it.
We will start by creating the serializers we’re going to be using to work with the request data and our models. The serializers perform easy conversion between types and provides an API to help us work with them from a higher level.
The serializer for the Account
model will be created using the ModelSerializer
class. Using this class, we can automatically create serializers from Django Models. The syntax for doing this is shown below.
from django.contrib.auth import update_session_auth_hash
from rest_framework import serializers
from .models import Account
class AccountSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, required=True)
confirm_password = serializers.CharField(write_only=True, required=True)
class Meta:
model = Account
fields = (
'id', 'email', 'username', 'date_created', 'date_modified',
'firstname', 'lastname', 'password', 'confirm_password')
read_only_fields = ('date_created', 'date_modified')
The code snippet does a number of things. Firstly, since we are creating a subclass of serializers.ModelSerializer
, we can specify a model within the class. Besides specifying the model, you will also see here that the fields
parameter was set to a tuple of fields represented as strings. This does a number of things but I will paint a couple of scenarios to give you a god idea of what is going on.
- If we choose to create new objects using the serializer, the
fields
property by default ensures that all the fields listed are provided in the request body. - The same thing applies for when data models are to be returned as JSON. The serializer ensures that all the listed fields are returned.
- See the section on Serializer Fields on the DRF docs for more information on serializer fields.
It is important to note here that, because we are using the
ModelSerializer
class, the types of the fields specified are set based on their types in the model. Options likerequired=True
on model fields would also set validation conditions when this data is passed from the client.
Examining the code a bit further, we notice that, we have specified some fields as read_only_fields
. As you would guess, these fields will be viewable when the model object is being transformed to JSON but will not be required when doing a Create or Update action.
The final thing to note is the manual creation of the password
and confirm_password
fields. As you can see, they are required but also set as write_only
. What this means is that when the data is being converted from model instance to JSON, the fields will not be displayed. On the other hand, when you are doing any write operations, like Create and Update, the fields will be required.
We will add some more logic in the AccountSerializer
class to give us a bit more flexibility when working with it.
from django.contrib.auth import update_session_auth_hash
from rest_framework import serializers
from .models import Account
class AccountSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, required=True)
confirm_password = serializers.CharField(write_only=True, required=True)
class Meta:
model = Account
fields = (
'id', 'email', 'username', 'date_created', 'date_modified',
'firstname', 'lastname', 'password', 'confirm_password')
read_only_fields = ('date_created', 'date_modified')
def create(self, validated_data):
return Account.objects.create_user(**validated_data)
def update(self, instance, validated_data):
instance.email = validated_data.get('email', instance.email)
instance.username = validated_data.get('username',
instance.username)
instance.firstname = validated_data.get('firstname',
instance.firstname)
instance.lastname = validated_data.get('lastname',
instance.lastname)
password = validated_data.get('password', None)
confirm_password = validated_data.get('confirm_password', None)
if password and password == confirm_password:
instance.set_password(password)
instance.save()
return instance
def validate(self, data):
'''
Ensure the passwords are the same
'''
if data['password']:
print "Here"
if data['password'] != data['confirm_password']:
raise serializers.ValidationError(
"The passwords have to be the same"
)
return data
I will briefly explain what each method does.
-
create()
- This method is available in theModelSerializer
class. This method is called when we are performing a create operation using our serializer. By default, the method is configured to call theModel.objects.create()
method of whatever Model we specified. The only problem with this is the fact that the password is set differently. We could choose here to either overwrite our Manager classcreate()
method or call thecreate_user()
method instead. We obviously pick the second approach and this is already because we’ve properly implemented the said method to set the password properly as required. -
update()
- Unlike thecreate()
method discussed above, theupdate()
method comes with two parameters. The first parameter represents the instance while the second represents the keyword arguments. You’ll notice that we’re selectively updating the different fields only if they were set in the request. With the password, we are ensuring that thepassword
is the same asconfirm_password
before saving and returning the instance. -
validate()
- This method is called when the serializer.isvalid()
method is called. We can put in general validation logic for the data being passed from the client. We are passed adata
field as the second parameter and this contains all the data passed in the request as a dictionary. We have done some validation logic here to ensure that the passwords are the same before creating a new object.
That’s it for the serializer!
Next up, we create the view logic for the registration process. This would involve getting the data from the user and creating a new account object using that information.
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from .serializers import AccountSerializer
from .models import Account
class AuthRegister(APIView):
"""
Register a new user.
"""
serializer_class = AccountSerializer
permission_classes = (AllowAny,)
def post(self, request, format=None):
serializer = self.serializer_class(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
We have made use of the APIView
. Using this we are able to write our view functions as classes. Similar to Django’s CBVs, we have created a post()
method to handle POST transactions to the URL. In the post logic, as you can see, we created a serializer object using the form data and then called the is_valid()
function to validate the fields against the serialzer. If this data is valid we proceed to save the object and return a success message.
As you would guess, the save()
method is automatically calling the serializer create()
method.
An error status is returned if there’s an error validating.
We will now be creating the route for this function in the authentication.urls
module. Our file should now look like this.
from django.conf.urls import include, url
from .views import AuthLogin, AuthRegister
from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token, verify_jwt_token
urlpatterns = [
url(r'^login/', obtain_jwt_token),
url(r'^token-refresh/', refresh_jwt_token),
url(r'^token-verify/', verify_jwt_token),
url(r'^register/$', AuthRegister.as_view()),
]
I have included some other Rest Framework JWT views for dealing with expired token or verifying tokens. All these URLs will be prefixed with /api/v1/auth/
as expected.
That’s it! We’re done with the authentication system. You can now create and run your migrations to get started. After this, you can then test the API thoroughly using POSTMAN.
Watch out for the next article.
UPDATE
I got a number of complaints about the code snippets in this article not working. I have uploaded a working version of the app which I was using for this demonstration here. Feel free to compare your code with what’s in there.
If you still have questions, feel free to send me an e-mail at chidiebere.nnadi@gmail.com.
Cheers.