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
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:
- 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}")
- Running the Code in the Terminal: You can also execute the above code directly in your terminal.
- 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:
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!