JWT Authentication with Django REST Framework - What? Why? How?

bhavanaeh

Bhavana

Posted on June 19, 2021

JWT Authentication with Django REST Framework - What? Why? How?

Hi community, I have had a lot of trouble understanding the use of authentication mechanisms and securing API endpoints until recently, when I was expected to implement JWT authentication to secure the APIs being used in my project. I had to do my fair amount of reading and research which made me want to take a stab at helping others understand what JWT Authentication is, why it is needed and how you can implement it in your project to ensure user authentication.


Problem

My approach to perform authentication has been on the following lines -

My REST API should ask for a username and password. Once provided, it will be used to filter out the database to check if a user with those credentials exist.

Sounded logical to me.

But, the real problem is the stateless nature of the HTTP protocol. This meant that anytime, a new request was issued, the user issuing the request would have to be authenticated AGAIN. Luckily, Django has its own session based authentication system. In Django, 'sessions' are stored as cookies. This session-based authentication is stateful. Each time a client requests the server, the server locates the session in memory in order to map the session ID back to the requested user.These sessions, ensure that there is a user returned every time a request is made. The user can be accessed as request.user.

So, if Django has a default session authentication system, then what REALLY is the problem?

Django's authentication only works with the traditional HTML request-response cycle

What that means is anytime a request is made to the server, the server will have the control to process that request and would respond with HTML. The issue is the client side application does not follow the traditional HTML request-response cycle.Instead, the client expects the server to return JSON instead of HTML. By returning JSON, we can let the client decide what it should do next instead of letting the server decide. Thus, the JSON response does not control the behaviour of the browser, it just returns the result of the request made.

Another issue with Django's session based authentication is handling different domains for client and server, making it difficult to use sessions. What this means is, since Django sessions are stored in cookies, allowing external domains to access the cookies will make it vulnerable to CSRF attacks. CSRF attacks are events where authenticated users are tricked into performing malicious actions when the attacker gains access to these stored cookies. Moreover, DRF disables CSRF handling for APIViews making it difficult to rely on session based authentication (especially when using customs models, templates and views)

The most common alternative to session based authentication is the token based authentication system. Token based authentication as the name suggests, generates a token (by the server) each time the user is logged in successfully, mapping the token generated with the user and storing it in localStorage in the client side. The client will now be expected to send this generated token as a header for every subsequent request to authenticate the user. If a token for the said user exists in the localStorage, then the user is authenticated.Since these token are the only requirement for the server to verify a user's identity, it is stateless.The most popular token based authentication with REST APIs is the JWT (JSON Web Token) Authentication. It is an encoded (secure) representation of claims to between two parties.

Sounds fairly simple but-

Storing a generated token in a localStorage makes the system vulnerable to XSS attacks. XSS attacks are a type of malicious injections through the client side that can easily manipulate the localStorage

SO WHAT NOW?

  1. Need to figure out a way to store the tokens in the client-side ( Can't use localStorage since its prone to XSS attacks )
  2. Can't use session based auth since cookies are prone to CSRF attacks.

Proposed Solution

Lets understand the process flow alongside the code.

step 1: Setup your project (I'll call mine jwtauth) and install the following dependencies:



pip install django django-cors-headers djangorestframework PyJWT

django-admin startproject jwtauth
cd jwtauth
django-admin startapp demo


Enter fullscreen mode Exit fullscreen mode

step 1: Modify your jwtauth/settings.py file




INSTALLED_APPS = [
    ...
    'corsheaders',
    'rest_framework',
    ...
    'app_name' #demo in my example

]

MIDDLEWARE = [
    ...
    'corsheaders.middleware.CorsMiddleware',
    'django.middleware.common.CommonMiddleware',
    ...
]

CORS_ALLOW_CREDENTIALS = True 
CORS_ORIGIN_WHITELIST = ['http://localhost:3000']
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
     'demo.verify.JWTAuthentication',
    ),    
    'DEFAULT_PERMISSION_CLASSES': (
    'rest_framework.permissions.IsAuthenticated',
    )
}



Enter fullscreen mode Exit fullscreen mode

CORS_ALLOW_CREDENTIALS = True allows cookies to be sent in cross-domain responses
CORS_ORIGIN_WHITELIST = ['http://localhost:3000'] is the domain for your front-end application or where your client is.

step 2: Create a superuser



python manage.py createsuper


Enter fullscreen mode Exit fullscreen mode

step 3: Create a user serializer so that the user api can return the details as a JSON object



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

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = get_user_model()
        fields = ['id', 'username', 'email','first_name','is_active']



Enter fullscreen mode Exit fullscreen mode

step 4: Create a new python file under demo as auth.py (demo/auth.py) to generate and refresh access tokens



import datetime
import jwt
from django.conf import settings

def generate_access_token(user):

    access_token_payload = {
        'user_id': user.id,
        'exp': datetime.datetime.utcnow() + datetime.timedelta(days=0, minutes=5),
        'iat': datetime.datetime.utcnow(),
    }
    access_token = jwt.encode(access_token_payload,
                              settings.SECRET_KEY, algorithm='HS256')
    return access_token


def generate_refresh_token(user):
    refresh_token_payload = {
        'user_id': user.id,
        'exp': datetime.datetime.utcnow() + datetime.timedelta(days=7),
        'iat': datetime.datetime.utcnow()
    }
    refresh_token = jwt.encode(
        refresh_token_payload, settings.SECRET_KEY, algorithm='HS256')

    return refresh_token


Enter fullscreen mode Exit fullscreen mode

step 5: Create a user view and url in demo/views.py and demo/urls.py respectively



from django.shortcuts import render
from django.contrib.auth.models import User
from .serializers import UserSerializer
from rest_framework import viewsets
from rest_framework.decorators import api_view
from rest_framework.response import Response
from django.contrib.auth import get_user_model
from rest_framework import exceptions
from rest_framework.permissions import AllowAny
from rest_framework.decorators import api_view, permission_classes
from django.views.decorators.csrf import ensure_csrf_cookie
from .auth import generate_access_token, generate_refresh_token
from django.views.decorators.csrf import csrf_protect
import jwt
from django.conf import settings

@api_view(['GET'])
def user(request):
    user = request.user
    serialized_user = UserSerializer(user).data
    return Response({'user': serialized_user })


Enter fullscreen mode Exit fullscreen mode


from django.urls import include, path
from .views import user,login_view
from rest_framework.routers import DefaultRouter


urlpatterns = [
    path('user', user, name='user'),


Enter fullscreen mode Exit fullscreen mode

step 6: Create a login view and url in demo/views.py and demo/urls.py respectively



@api_view(['POST'])
@permission_classes([AllowAny])
@ensure_csrf_cookie
def login_view(request):
    User = get_user_model()
    username = request.data.get('username')
    password = request.data.get('password')
    response = Response()
    if (username is None) or (password is None):
        raise exceptions.AuthenticationFailed(
            'username and password required')

    user = User.objects.filter(username=username).first()
    if(user is None):
        raise exceptions.AuthenticationFailed('user not found')
    if (not user.check_password(password)):
        raise exceptions.AuthenticationFailed('wrong password')

    serialized_user = UserSerializer(user).data

    access_token = generate_access_token(user)
    refresh_token = generate_refresh_token(user)

    response.set_cookie(key='refreshtoken', value=refresh_token, httponly=True)
    response.data = {
        'access_token': access_token,
        'user': serialized_user,
    }

    return response


Enter fullscreen mode Exit fullscreen mode


from django.urls import include, path
from .views import user,login_view
from rest_framework.routers import DefaultRouter


urlpatterns = [
    path('user', user, name='user'),
    path('login',login_view,name='login'),
]


Enter fullscreen mode Exit fullscreen mode

To understand this step is crucial.

The login_view API endpoint has three decorators :

@api_view(['POST']) - Allows a post request with username and password in the body of the request
@permission_classes([AllowAny]) - Makes the login view public
@ensure_csrf_cookie - Enforces DRF to send CSRF cookie as a response in case of a successful login

Now,

  1. we have an access_token in the response body
  2. a refreshtoken as a HttpOnly cookie
  3. a CSRF token as a normal cookie which can be consumed by the frontend easily

Now, lets try to test this out -

go to http://127.0.0.1:8000/users/login and enter username and password in the request body. Ensure that a user with these credentials is registered in your database, if not go to http://127.0.0.1:8000/admin/ and create a set of test users by logging in with the superuser credentials.

image

A successful entry would generate the following response -

image

if you hit http://127.0.0.1:8000/users/login on postman you'll be able to see the following-

image

that we have successfully generated two cookies

  1. a refreshtoken as a HttpOnly cookie
  2. a CSRF token as a normal cookie

step 7: Create a new python file as verify.py under demo (demo/verify.py)

Since DRF enforces CSRF only in the session authentication, we need to ensure DRF enforces CSRF for API views as well, that's where the decorator @ensure_csrf_cookie comes into picture.

Lets define that in our custom authentication file verify.py



import jwt
from rest_framework.authentication import BaseAuthentication
from django.middleware.csrf import CsrfViewMiddleware
from rest_framework import exceptions
from django.conf import settings
from django.contrib.auth import get_user_model


class CSRFCheck(CsrfViewMiddleware):
    def _reject(self, request, reason):
        return reason


class JWTAuthentication(BaseAuthentication):

    def authenticate(self, request):

        User = get_user_model()
        authorization_heaader = request.headers.get('Authorization')

        if not authorization_heaader:
            return None
        try:
            access_token = authorization_heaader.split(' ')[1]
            payload = jwt.decode(
                access_token, settings.SECRET_KEY, algorithms=['HS256'])

        except jwt.ExpiredSignatureError:
            raise exceptions.AuthenticationFailed('access_token expired')
        except IndexError:
            raise exceptions.AuthenticationFailed('Token prefix missing')

        user = User.objects.filter(id=payload['user_id']).first()
        if user is None:
            raise exceptions.AuthenticationFailed('User not found')

        if not user.is_active:
            raise exceptions.AuthenticationFailed('user is inactive')

        self.enforce_csrf(request)
        return (user, None)

    def enforce_csrf(self, request):
        check = CSRFCheck()
        check.process_request(request)
        reason = check.process_view(request, None, (), {})
        print(reason)
        if reason:
            raise exceptions.PermissionDenied('CSRF Failed: %s' % reason)


Enter fullscreen mode Exit fullscreen mode

Open postman again, and under the headers tab add the following as your key value pair, please provide the value in the following format token<space>access_token_generated

Capture3

You might then face the following error -

Capture

This is because the frontend is supposed to pass the CSRF Token generated back to the server in the header, but for testing purposes you can proceed by manually adding the X-CSRFToken value pair -

Capture2

step 8: Create the refresh token view under demo/views.py and add its url to demo/urls.py



@api_view(['POST'])
@permission_classes([AllowAny])
@csrf_protect
def refresh_token_view(request):
    User = get_user_model()
    refresh_token = request.COOKIES.get('refreshtoken')
    if refresh_token is None:
        raise exceptions.AuthenticationFailed(
            'Authentication credentials were not provided.')
    try:
        payload = jwt.decode(
            refresh_token, settings.REFRESH_TOKEN_SECRET, algorithms=['HS256'])
    except jwt.ExpiredSignatureError:
        raise exceptions.AuthenticationFailed(
            'expired refresh token, please login again.')

    user = User.objects.filter(id=payload.get('user_id')).first()
    if user is None:
        raise exceptions.AuthenticationFailed('User not found')

    if not user.is_active:
        raise exceptions.AuthenticationFailed('user is inactive')


    access_token = generate_access_token(user)
    return Response({'access_token': access_token})


Enter fullscreen mode Exit fullscreen mode

Important points to note here are,
to generate a new access token the request expects -

  1. A cookie that contains a valid refresh_token
  2. A header 'X-CSRFTOKEN' with a valid csrf token
  3. Only if the refresh_token is invalid or has expired, the user will need to re-login.

Thus, if everything worked fine, you should be able to see something on the same lines-

tempsnip

Conclusion

I had a really hard time understanding the whole mechanism of securing endpoints and why it is really needed. Through this post I hope I can be of help to people like me, struggling to understand and implement JWT authentication in the most optimal way. As always, I am open to thoughts, queries and discussions. I'd love to understand if there is a more optimal approach to the problem.


References ( some really good reads )

  1. The source code - https://dev.to/a_atalla/django-rest-framework-custom-jwt-authentication-5n5 (have modified it a little in this post)

  2. Difference between Session Based and Token Based Mechanisms - https://dev.to/thecodearcher/what-really-is-the-difference-between-session-and-token-based-authentication-2o39

  3. The request-response cycle in Django - https://medium.com/@ksarthak4ever/django-request-response-cycle-2626e9e8606e

  4. CSRF Attacks - https://dev.to/_smellycode/csrf-in-action-21n3

  5. XSS Attacks - https://dev.to/kmistele/xss-what-it-is-how-it-works-and-how-to-prevent-it-589o

  6. Why JWT in Django? https://stackoverflow.com/questions/31600497/django-drf-token-based-authentication-vs-json-web-token

💖 💪 🙅 🚩
bhavanaeh
Bhavana

Posted on June 19, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related