Building REST API with Django (Part 1)

Authentication with JWT

In this article, we will be implementing a custom user in Django (Note: We are not going to use the Django user model to avoid some unnecessary complexities). We are also going to return JWT tokens and use middleware to validate them and also protect some routes. You must have some experience with Django before reading this article. You can get started here.

First of all, let's set up the project

virtualenv venv && source venv/bin/activate
django-admin startproject sample_rest_api
cd sample_rest_api
django-admin startapp accounts

Do not forget to use virtualenv Your folder structure should look like this

├── accounts
│ ├── admin.py
│ ├── apps.py
│ ├── __init__.py
│ ├── migrations
│ ├── models.py
│ ├── tests.py
│ └── views.py
├── manage.py
├── sample_rest_api
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
└── venv

We are going to be using some modules from the Django Rest Framework so we should install it

pip install djangorestframework

Add the name of the app you created to INSTALLED_APPS in sample_rest_api/settings.py and rest_framework

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

Before anything else, we create a urls.py file in the accounts directory that looks like this:


from django.urls import path
from accounts import views

app_name = 'accounts'

urlpatterns = [
]

Then we include it in the main sample_rest_api/urls.py file:

"""sample_rest_api URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
    https://docs.djangoproject.com/en/2.0/topics/http/urls/
Examples:
Function views
    1. Add an import:  from my_app import views
    2. Add a URL to urlpatterns:  path('', views.home, name='home')
Class-based views
    1. Add an import:  from other_app.views import Home
    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
Including another URLconf
    1. Import the include() function: from django.urls import include, path
    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/accounts/', include('accounts.urls', namespace='accounts')),
]

Let’s now go to our accounts/models.py to create a custom user model with only the email and password field.

from django.db import models

# Create your models here.

class CustomUser(models.Model):
    email = models.EmailField(max_length=50, unique=True, blank=False, null=False)
    password = models.CharField(max_length=50, blank=False, null=False)

    def __str__(self):
        return self.email

Run the next commands and fill in the required values to migrate and also create a superuser.

python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser

Next, we create our serializers.py file. It works mainly like Django forms and you can read more on it in the Django Rest Framework documentation. For this part, we need to install another module called bcrypt. Use the command:

pip install bcrypt

You can also check the documentation for further information on it. Our serializer.py will look like this:

from rest_framework import serializers
from accounts.models import CustomUser as User
import bcrypt

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ('email', 'password')

    def create(self, validated_data):
        email = validated_data.get('email')
        # strings must be encoded because of bcrypt
        unhashed_password = validated_data.get('password').encode('utf-8')
        hashed_password = bcrypt.hashpw(unhashed_password, bcrypt.gensalt())
        # It's decoded because it is currently in bytes instead of string
        return User.objects.create(email=email, password=hashed_password.decode('utf-8'))

The serializers.ModelSerializer acts the same way as forms.ModelForm in Django. The create function will be executed when the data is to be saved. It takes the validated data, gets the password and hashes it with the bcrpyt library.

The reason for the .encode('utf-8') and .decode('utf-8') is because the bcrypt.hashpw function only accept bytes and the encode function converts strings to bytes, the decode function does the opposite.

For this part, we need to install PyJWT.

pip install pyjwt

Then we update our views.py file:

from django.shortcuts import render
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from rest_framework.parsers import JSONParser
import jwt
import bcrypt

from accounts.models import CustomUser as User
from accounts.serializers import UserSerializer

# Create your views here.
def create_jwt(email):
    encoded_jwt = jwt.encode({'email': email}, 'MICHAEL_JACKSON', algorithm='HS256').decode('utf-8')
    return encoded_jwt

@csrf_exempt
def create_user(request):
    if request.method == "POST":
        data = JSONParser().parse(request)
        serializer = UserSerializer(data=data)
        if serializer.is_valid():
            serializer.save()
            # It is just a normal python dictionary
            user_data = serializer.data
            # creating a jwt token
            token = create_jwt(user_data.get('email'))
            return JsonResponse({"message": "User Created Successfully", "token": token}, status=201)
        else:
            return JsonResponse({"errors": serializer.errors}, status=400)
    else:
        return JsonResponse({"errors": "You must send a POST request"})

The @csrf_exempt decorator is used when using a POST request. Normally when submitting forms in normal Django, we have to add a csrf_token for the form to get to the view or it will be rejected by the csrf middleware in Django. This decorator exempts from having to send the token.

The JSONParser().parse(request) is used to parse the request and return the body of POST request which will then be saved by the serializer if it is valid else, it will be rejected and an error will be sent back.

We get the email from the saved data and use it to create a jwt token using the library installed previously and send a response with the token back to the client.

We add this route to our urls.py file

from django.urls import path
from accounts import views

app_name = 'accounts'

urlpatterns = [
    path('signup', views.create_user, name='signup'),
]

We can now write our login view:

@csrf_exempt
def login_user(request):
    if request.method == "POST":
        data = JSONParser().parse(request)
        email = data.get('email')
        password = data.get('password').encode('utf-8')
        try:
            user = User.objects.get(email=email)
        except User.DoesNotExist:
            return JsonResponse({"errors": "Invalid Credentials"})

        serializer = UserSerializer(user)
        user_data = serializer.data
        hashed_password = user_data.get('password').encode('utf-8')
        if bcrypt.checkpw(password, hashed_password):
            token = create_jwt(email)
            return JsonResponse({"message": "User Login Successful", "token": token}, status=200)
        else:
            return JsonResponse({"errors": "Invalid Credentials"})
    else:
    return JsonResponse({"errors": "You must send a POST request"})

We get the email and password from the request body and we try to get a user with a corresponding email. If the user doesn’t exist, an error message is sent back to the user.

If the user exists, we get the hashed password and use bcrypt to compare them, if they match, a token is created and sent back to the user.

The path is also added to accounts/urls.py

from django.urls import path
from accounts import views

app_name = 'accounts'

urlpatterns = [
    path('signup', views.create_user, name='signup'),
    path('login', views.login_user, name='login'),
]

Next, we are going to create a middleware to check from JWT tokens in the request header before we permit access to paths on the server except for the admin, signup and login path.

from django.urls import reverse
import jwt
from django.http import JsonResponse


class JwtAuthMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        return response

    def process_view(self, request, view_func, view_args, view_kwargs):
        if request.path.startswith(reverse('admin:index')) \
                                    or request.path.startswith(reverse('accounts:login')) \
                                    or request.path.startswith(reverse('accounts:signup')):
            return None 
        else:
            auth_header = request.META.get('HTTP_AUTHORIZATION')
            if (auth_header):
                token = auth_header.split(' ')[1]
                try:
                    payload = jwt.decode(token, 'MICHAEL_JACKSON', algorithms=['HS256'])
                except jwt.ExpiredSignatureError:
                    return JsonResponse({"errors": "Token Expired"}, status=401)
                except jwt.DecodeError:
                    return JsonResponse({"errors": "Invalid Token"}, status=401)
                request.META['AUTH_USER'] = payload
            else:
                return JsonResponse({"errors": "You are not authorized"}, status=401)

The process_view function is executed before calling the view and if it returns None, the view is executed. We use the reverse library to check with the request path for the signup, login and admin routes, if they match, we allow the request to continue, else we get the Authorization header represented as HTTP_AUTHORIZATION.

The header should be sent like this: Authorization: Bearer <token> the value of the token is gotten from the header and decoded, if the token is expired or invalid, an appropriate response is sent back to the client else, we add a key to the request.META dictionary called AUTH_USER which is the payload of the JWT. The payload can then be accessed in the views function.

Next, we create a views function that can only be accessed by a valid JWT.

def protected(request):
    print(request.META.get('AUTH_USER'))
    return JsonResponse({"message": "Congrats on entering the secret region"})

In this view function, we print the AUTH_USER field that was added by the middleware token just to confirm it is working fine.

The path is also added to the accounts/urls.py file. The complete accounts/urls.py file is:

from django.urls import path
from accounts import views

app_name = 'accounts'

urlpatterns = [
    path('signup', views.create_user, name='signup'),
    path('login', views.login_user, name='login'),
    path('secret', views.protected, name='secret'),
]

That is all. If a request is to be sent to the path /api/accounts/secret, it must have a valid JWT in its header else the request will not be completed.

In the next article, we are going to be talking about image/file upload through api requests.

No Comments Yet