Exploring JWT Authentication in Django : Part 2 - Setting Up Auth APIs

Posted on: 21 September 2024 | Last updated: 21 September 2024 | Read: 14 min read | In: Django, Python

Description

In this blog series, we explore implementing JWT authentication in Django. In Part 2, we will create auth endpoints like login, register, and get-me APIs. In the next part we will setup social authentication like google, and github.

Full blog

Hello and welcome to part 2 of this blog series where we will exploring JWT authentication is Django. In this blog, we will be setting djangorestframework-simplejwt and will have JWT authentication by the end of the blog.

1. Install packages

In the first part of this blog series, we installed all the necessary packages. However, let's reinstall them in case something was missed, or if you're joining us now. To install the packages, run the following command:

pip install djangorestframework-simplejwt django-cors-headers

django-cors-headers is optional but I keep it as most of my projects requires cors-header configuration.

2. Generating JWT secret key

To sign and verify tokens, we need to generate a secret JWT key. You can create this key using one of the following methods:

  1. Using Python: You can run the below python code to get yourself a secret key:
import secrets

def generate_jwt_secret_key():
    return secrets.token_hex(32)  # 32 bytes = 64 hex characters

if __name__ == "__main__":
    secret_key = generate_jwt_secret_key()
    print(f"Your JWT Secret Key: {secret_key}")
  1. Running the Code in the Terminal: You can also execute the above code directly in your terminal.
  2. Using Online Tools: You can use online tools to generate a secret key. I recommend this site.

3. Updating environment

Next, let's add the JWT_SECRET_KEY to our environment. Since we are using a .env file to manage our environment variables, I will update my .env file as follows:

SECRET_KEY = "<- secure key ->"
DEBUG = True
DATABASE_URL = "postgres://postgres:123@localhost:5432/confetti"
JWT_SECRET_KEY = "<- super secret jwt key ->"

Please ensure that you don’t share your secret keys with anyone and take precautions to protect them from leaks.

4. Setting up packages

Let's start with updating our installed apps. Since we want to use django-cors-headers and djangorestframework-simplejwt, we will modify our installed apps to be:

# confetti/settings.py

INSTALLED_APPS = [
    ...
    "rest_framework",
    "rest_framework.authtoken",
    "rest_framework_simplejwt.token_blacklist", # optional
    "rest_framework_simplejwt",
    "corsheaders",
]

For django-cors-headers to work we also have to update middlewares. Here's how we will add django-cors-headers in the middleware:

# confetti/settings.py

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "corsheaders.middleware.CorsMiddleware",
    ...
]

Note: The order of middleware is important, so make sure it's according to the django-cors-headers docs.

Next, let's configure a few things related to cors. You may want to adjust these for your product application, but for purpose of this blog we will keep it simple. I recommend reviewing the docs to learn more about these configurations.

# confetti/settings.py

CORS_URLS_REGEX = r"^/api/.*$"
CORS_ALLOW_ALL_ORIGINS = True

Not, let's setup djangorestframework to use JWT authentication.

# confetti/settings.py

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": (
        "rest_framework.authentication.SessionAuthentication",
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ),
    "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), # optional but good to have
}

Finally, we can setup djangorestframework-simplejwt by tuning a few options. These parameters will change depending on your application and use case, so refer to the docs for more insights.

Here's what I like to have in my projects:

# confetti/settings.py

import os
from datetime import timedelta

SIMPLE_JWT = {
    "SIGNING_KEY": os.environ['JWT_SECRET_KEY'],
    "ACCESS_TOKEN_LIFETIME": timedelta(weeks=1),
    "REFRESH_TOKEN_LIFETIME": timedelta(weeks=4),
    "ROTATE_REFRESH_TOKENS": False,
    "BLACKLIST_AFTER_ROTATION": False,
    "UPDATE_LAST_LOGIN": False,
    "ALGORITHM": "HS256",
    "VERIFYING_KEY": None,
    "AUDIENCE": None,
    "ISSUER": None,
    "JWK_URL": None,
    "LEEWAY": 0,
    "AUTH_HEADER_TYPES": ("Bearer",),
    "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
    "USER_ID_FIELD": "id",
    "USER_ID_CLAIM": "user_id",
    "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule",
    "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
    "TOKEN_TYPE_CLAIM": "token_type",
    "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser",
    "JTI_CLAIM": "jti",
    "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp",
    "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5),
    "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
}

Before we proceed, I recommend you to at least skim through the documentations of:

  1. Django RestFrameworks
  2. Django RestFrameworks SimpleJWT
  3. Django CORS Header

5. Basic understanding

In this section, we'll cover some fundamentals. This sections assumes that you already familiar with JWT authentication is. We are using djangorestframework-simplejwt to generate, verify and save JWT tokens. In Django community, it's common for developers to use djoser, knox or any other packages on top of djangorestframework-simplejwt to simplify some trivial task such as login-in, sign-up, email verification, social auth, etc. While these libraries can be beneficial, I typically don't use these libraries as they do not offer much flexibility.

You can chose to use these libraries, in-fact I encourage you to use these libraries as this gives you a taste on how things work in different libraries. But for purpose of thing blog, I will only use djangorestframework-simplejwt and implement all the functionality I need myself.

While JWT authentication is stateless, djangorestframework-simplejwt is not stateless. djangorestframework-simplejwt is not stateless as it makes query to database to get fresh user information and for things like token blacklisting. For my projects, these are actually useful and I prefer these over stateless authentication. But if your project needs to have stateless authentication to facilitate single sign-on functionality between separately hosted Django apps or any other reason, you can read the docs to learn more about how you can implement stateless authentication in djangorestframework-simplejwt.

TL:DR: djangorestframework-simplejwt is not stateless by default. While we can use packages such as Djoser, Knox, all-auth, etc to handle functionalities such as email verification, login, signup, and password management, we will just stick to djangorestframework-simplejwt and implement everything ourself.

6. Implementing login API

Now that we have everything set up, let's implement the login API. I prefer my login API to return user information along with the access token and refresh token. The user information will use the same serializer as the users/me API. This approach provides the client not only with a token but also with user information for state management.

We will create two serializers for our login API.

First, let's define the user serializer:

# users/serializers.py

from django.contrib.auth import get_user_model
from rest_framework import serializers

User = get_user_model()

class UserSerializer(serializers.ModelSerializer):

    class Meta:
        model = User
        fields = (
            "id",
            "username",
            "first_name",
            "last_name",
            "email",
        )

Next, we’ll define the login serializer with authentication logic:

# users/serializers.py

from django.contrib.auth import authenticate
from rest_framework import serializers

class LoginSerializer(serializers.Serializer):
    email = serializers.EmailField()
    password = serializers.CharField()

    def validate(self, attrs):
        user = authenticate(**attrs)
        if user and user.is_active:
            return user
        raise serializers.ValidationError("Incorrect Credentials")

    def create(self, _):
        # to surpass lint errors
        pass

    def update(self, _, __):
        # to surpass lint errors
        pass

The UserSerializer contains all the necessary user information. Since we are exposing fields such as email, we should use this serializer only for 'private' methods and APIs.

Now that we have login serializer ready, let's use it in our API view.

# users/views.py

from rest_framework import generics, permissions
from rest_framework.response import Response

from .serializers import UserSerializer, LoginSerializer
from .utils import UserToken

class LoginAPI(generics.GenericAPIView):
    serializer_class = LoginSerializer
    permission_classes = [permissions.AllowAny]

    def post(self, request):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        user = serializer.validated_data

        token = UserToken.get_token(user)

        return Response(
            {
                "user": UserSerializer(user, context=self.get_serializer_context()).data,
                "refresh_token": token["refresh_token"],
                "access_token": token["access_token"],
            }
        )

The Login API uses the LoginSerializer to verify and authenticate the user, returning the relevant data. Here’s how the UserToken class looks:

# users/utils.py

from rest_framework_simplejwt.tokens import RefreshToken

class UserToken:
    @staticmethod
    def get_token(user):
        token = RefreshToken.for_user(user)

        return {
            "refresh_token": str(token),
            "access_token": str(token.access_token),
        }

The UserToken utilizes RefreshToken to generate both access and refresh token. Perhaps this class is over-engineered so you can simplify it by just making a get_token method. Finally, let's expose this API to the client.

# users/urls.py\

from django.urls import path

from .views import LoginAPI

urlpatterns = [
    path("login/", LoginAPI.as_view(), name="login"),
]
# confetti/urls.py

from django.urls import path, include

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

To test this endpoint you can make curl request using the following command, replace with your actual email and password:

curl -X POST http://127.0.0.1:8000/users/api/login/ \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "yourpassword"}'

Note: Since we don't have register endpoint yet, you can create user account using createsuperuser command.

7. Implementing register API

The implementation of the register API can vary depending on your application's requirements. Some applications may require users to verify their email for login, others may enforce two-factor authentication, and some might not even have a registration API at all. All this means that your implementation can change based on your needs.

For my applications, I prefer to return all user information along with the access and refresh tokens upon registration. I typically don't require users to verify their email, but you can adjust the implementation as needed.

Here’s how my RegisterSerializer usually looks:

# users/serializers.py

class RegisterSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = (
            "username",
            "first_name",
            "last_name",
            "email",
            "password",
        )
        extra_kwargs = {
            "password": {"write_only": True},
        }

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

And here's what the views and urls looks:

# users/views.py

from rest_framework import generics, permissions
from rest_framework.response import Response

class RegisterAPI(generics.GenericAPIView):
    serializer_class = RegisterSerializer
    permission_classes = [permissions.AllowAny]

    def post(self, request):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        user = serializer.save()

        token = UserToken.get_token(user)

        return Response(
            {
                "user": UserSerializer(user, context=self.get_serializer_context()).data,
                "refresh_token": token["refresh_token"],
                "access_token": token["access_token"],
            }
        )

And finally, here’s how to set up the URL configuration:

# users/urls.py

from django.urls import path
from .views import RegisterAPI

urlpatterns = [
    ...
    path("register/", RegisterAPI.as_view(), name="register"),
]

8. Implementing 'get me' API

Often, our clients require a "get me" API that returns all user information using the user's access token, as well as the ability to update user information. In this implementation, we will leverage the UserSerializer that we created earlier. Let’s set up the views and URLs.

# users/views.py

from rest_framework import generics, permissions
from rest_framework.response import Response

class UserAPI(generics.GenericAPIView):
    permission_classes = [permissions.IsAuthenticated]
    serializer_class = UserSerializer

    def get_object(self):
        return self.request.user

    def get(self, _) -> Response:
        user = self.get_object()
        serializer = self.get_serializer(user)

        return Response(serializer.data)

    def put(self, request: Request) -> Response:
        user = self.get_object()
        serializer = self.get_serializer(user, data=request.data)
        serializer.is_valid(raise_exception=True)
        serializer.save()

        return Response(serializer.data)
# users/urls.py

from django.urls import path
from .views import UserAPI

urlpatterns = [
    ...,
    path("me/", UserAPI.as_view(), name="user"),
]

9. Implementing Token Refresh and Invalidation (Logout)

For token refresh and invalidation (or logout), we can utilize the views provided by djangorestframework-simplejwt since they don't require much customization.

# users/urls.py

from django.urls import path

from rest_framework_simplejwt.views import TokenBlacklistView, TokenRefreshView

urlpatterns = [
    path("logout/", TokenBlacklistView.as_view(), name="logout"),
    path("refresh-token/", TokenRefreshView.as_view(), name="refresh_token"),
]

Note: It is more secure to invalidate or blacklist a token rather than just removing it from the client side.

And that wraps up this section. In this blog, we learned about djangorestframework-simplejwt, its setup, and how to configure authentication routes. In the next part, we will explore how to implement social authentication. See you then!