Token Based User Authentication With Django

INTRODUCTION

When creating a REST API for client applications to communicate with, it is common to want it to have some authentication and permissions. In this post I would like to walk through the set up of the API for a Books collection application. The API allows users to be created, to get access token and to use it on future requests. This way unauthenticated users can see the list of books, but only authenticated users can create, edit or delete books.

For this we'll be using the Django Framework, with the Django REST Framework. If you still don't know them go ahead and check their website, they have great tutorials to get you started.

For making requests to test it, I find Insomnia really good, and hey they have a free tier 😎. Another client that I find really useful is the command line app HTTPie. I'll be using HTTPie for this tutorial, it makes it easier for you to try the commands on your own, and for diplaying the results in text instead of screenshots.

Finally I saved the result on Github, in case you get lost or just want to check the final result.

Withour further ado let's get to it.

CREATING THE PROJECT

For this project I will be using pipenv for package management, because it automatically creates and manages the virtual environment. In case you don't have it installed yet, it's quite easy to do it, you can follow the instructions from their documentation. You can also use venv, poetry, or any other tool you prefer.

Let's start by installing Django, and Django REST framework. Inside the folder you would like to create the project, run the command:

# Install the packages, and creates a virtual environment
pipenv install django djangorestframework

# Activates the virtual environment 
pipenv shell

Now we can create the project for our API.

django-admin startproject api .

I add the . character at the end to start the project in the current folder. This way I have the manage.py file in the same folder as Pipfile, and just one folder. So our folder structure should look like this

.
β”œβ”€β”€ api
β”‚   β”œβ”€β”€ asgi.py
β”‚   β”œβ”€β”€ __init__.py
β”‚   β”œβ”€β”€ settings.py
β”‚   β”œβ”€β”€ urls.py
β”‚   └── wsgi.py
β”œβ”€β”€ manage.py
β”œβ”€β”€ Pipfile
└── Pipfile.lock

If instead we hadn't used the ., we would have the following structure.

.
β”œβ”€β”€ api
β”‚   β”œβ”€β”€ api
β”‚   β”‚   β”œβ”€β”€ asgi.py
β”‚   β”‚   β”œβ”€β”€ __init__.py
β”‚   β”‚   β”œβ”€β”€ settings.py
β”‚   β”‚   β”œβ”€β”€ urls.py
β”‚   β”‚   └── wsgi.py
β”‚   └── manage.py
β”œβ”€β”€ Pipfile
└── Pipfile.lock

To tell our project that we are using the REST framework. Add it to INTALLED_APPS on api/settings.py.

INSTALLED_APPS = [
    'rest_framework',
    ...
]

Great, now that we have it installed we can create the database.

python manage.py migrate

This creates the database using the default SQL Lite, that is fine for this tutorial. In production however we would probably want to use a different database.

CREATING THE BOOKS APP

Now let's create our books app.

python manage.py startapp books

To tell our project that we are using the newly created app, add it to INSTALLED_APPS on api/settings.py

INSTALLED_APPS = [
    'books.apps.BooksConfig',
    ...
]

We'll create a simple model for books, since this isn't the focus of this tutorial. Edit the books/models.py file so it looks like this:

from django.db import models


class Book(models.Model):
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True

Now we can create the migration and sync the database.

# Create migrations for Books app
python manage.py makemigrations books

# Sync the database 
python manage.py migrate

Every time we add a new app to INSTALLED_APPS or change a model, we can tell Django to create the migrations, with the makemigrations command. When we're ready we can sync it with the migrate command.

Create the file books/serializers.py with the serializer for the Book model.

from rest_framework import serializers
from books.models import Book

class BookSerializer(serializers.ModelSerializer):
    class Meta:
        model = Book
        fields = ["id", "title", "description"]

And a view for creating new books and listing the existing ones. On books/views.py add the following.

from rest_framework import generics

from books.models import Book
from books.serializers import BookSerializer


class BookList(generics.ListCreateAPIView):
    queryset = Book.objects.all()
    serializer_class = BookSerializer

And connect the view to our books urls. Create the file books/urls.py and connect the view to the root of the route.

from django.urls import path
from books.views import BookList

urlpatterns = [
    path("", BookList.as_view(), name="index"),
]

And send the routes starting with books to the Book app, by adding it to api/urls.py:

from django.urls import include, path

urlpatterns = [
    ...,
    path("books/", include("books.urls")),
]

Now we can start the server to test the api.

python manage.py runserver

After running the system check it should start the development server.

Performing system checks...

System check identified no issues (0 silenced).
August 25, 2021 - 22:47:34
Django version 3.2.6, using settings 'api.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

Now if we send a POST request to http://127.0.0.1:8000/books/, with the book title and description.

http http://127.0.0.1:8000/books/ title='Cool book' description='Long description'

We'll have created the first book. And it gives the following response:

HTTP/1.1 201 Created
...

{
    "description": "Long description",
    "id": 1,
    "title": "Cool book"
}

Go ahead and try creating more books.

If we send a GET request to http http://127.0.0.1:8000/books/ we get a list of existing books.

http http://127.0.0.1:8000/books/

It should display a response similar to the following.

HTTP/1.1 200 OK
...

[
    {
        "description": "Long description",
        "id": 1,
        "title": "Cool book"
    },
    {
        "description": "Long description of another book",
        "id": 2,
        "title": "Fantasy book title"
    }
]

If we open the route http://127.0.0.1:8000/books in our browser we should see the following screen, where it lists all the books saved in our database, and we can use the POST method to add more books.

Go ahead and try it as well for adding some more books.

As you will notice, we can see all books, and add books without authentication.

Let's now change this behaviour, so that anyone can see the list of books, but only registered users can add new books.

CREATE USERS

Let's start by creating a separated app for handling our users. On the folder with the manage.pyfile run the command

python manage.py startapp users

install the users app to api/settings.py

INSTALLED_APPS = [
    'users.apps.UsersConfig',
        ...
]

Now for our user serializer we have something more interesting. Create the file
users/serializer.py and add the users serializer:

from rest_framework import serializers
from django.contrib.auth.models import User


class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ["username", "email", "password"]
        extra_kwargs = {"password": {"write_only": True}}

    def create(self, validated_data):
        user = User(email=validated_data["email"], username=validated_data["username"])
        user.set_password(validated_data["password"])
        user.save()
        return user

When creating a new user, we use the set_password method so that we store the password hash instead of the actual password on the database, for security reasons. We set the password field to write_only, this way we can send a password when creating the user, but when we fetch the users list, it is not returned.

Similar to our books view create the user view on the file users/views.py.

from django.contrib.auth.models import User
from users.serializers import UserSerializer
from rest_framework import generics


class UserCreate(generics.ListCreateAPIView):
    queryset = User.objects.all()
    serializer_class = UserSerializer

and create the urls for users on users/urls.py

from django.urls import path
from users import views

urlpatterns = [
    path('', views.UserCreate.as_view()),
]

Connect the app to users routes on api/urls.py:

urlpatterns = [
        ...,
    path("users/", include("users.urls")),
]

Now we can start the server again and create new users:

http http://127.0.0.1:8000/users/ username='Luke' email='[email protected]' password='lukespassword'

And we should get the following response.

HTTP/1.1 201 Created
...

{
    "email": "[email protected]",
    "username": "Luke"
}

When we send a GET request we'll get a list of all users.

http http://127.0.0.1:8000/users/

HTTP/1.1 200 OK
...

[
    {
        "email": "[email protected]",
        "username": "Luke"
    },
    {
        "email": "[email protected]",
        "username": "Leia"
    }
]

Notice that we can get the list of users, but the password is not returned.

USERS TOKEN

Right now we are able to create users, but they have no way to authenticate to our API. For that we will create a route where the user can provide his credentials and get a token which can be used when sending further requests.

To do this, first add the rest_framework.authtoken to our installed apps on api/settings.py.

INSTALLED_APPS = [
    'rest_framework.authtoken',
        ...,
]

To create the database tables for the tokens run the command:

python manage.py migrate

Django REST Framework already provides us with the view obtain_auth_token for handling user authentication. All we need is to connect it into a route path. Into users/urls.py add the import and plug the view.

from rest_framework.authtoken.views import obtain_auth_token


urlpatterns = [
...,
    path('login/', obtain_auth_token)
]

Now we can start the server and test it.

http http://127.0.0.1:8000/users/login/ username='Luke' password='lukespassword'

HTTP/1.1 200 OK
...

{
    "token": "6650c889f12dafb1cff9dd50b58254f47df67288"
}

Go ahead and try sending a wrong combination of username and password to see it denying, as expected.

Now we can send this token in the Authorization HTTP header in our future requests in the format bellow, this way the API can provide us with the correct permissions. For instance, for the token above, the header would be:

Authorization: Token 6650c889f12dafb1cff9dd50b58254f47df67288

Notice the space between the word Token and the token itself.

PROTECT ROUTES WITH TOKEN

The last step is for us to protect our views with the proper permissions. We want to allow any user to see the list of books, but only authenticated users to be able to create new books.

In books/views.py, set permission_classes to tell it the type of permission we want. Also set authentication_classes to the authentication method being used.

from rest_framework.authentication import TokenAuthentication
from rest_framework.permissions import IsAuthenticatedOrReadOnly


class BookList(generics.ListCreateAPIView):
    queryset = Book.objects.all()
    serializer_class = BookSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]
    authentication_classes = [TokenAuthentication]

Now if we try creating a new book with an unauthorized user it denies it.

http http://127.0.0.1:8000/books/ title='Sad book' description="I won't work"


HTTP/1.1 401 Unauthorized
...

{
    "detail": "Authentication credentials were not provided."
}

If you substitute the X bellow with the token returned for your user, it works!

http http://127.0.0.1:8000/books/ title='Happy book' description="I work" Authorization:"Token XXXXXXXXXXXXXXXXX"


HTTP/1.1 201 Created
...

{
    "description": "I work",
    "id": 3,
    "title": "Happy book"
}

And that's it! πŸŽ‰πŸŽ‰πŸŽ‰

CONCLUSION

Having it working I would encourage you to check out the following documentations to learn more about the topic and making it more robust:

I hope you enjoyed it and that this content might be useful to someone. It was my first blog post but I tried writing about a subject the I struggled with. I greatly appreciate comments, feedback and suggestions for other topics you would like me to write about.

34