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?
- Need to figure out a way to store the tokens in the client-side ( Can't use localStorage since its prone to XSS attacks )
- 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
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',
)
}
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
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']
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
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 })
from django.urls import include, path
from .views import user,login_view
from rest_framework.routers import DefaultRouter
urlpatterns = [
path('user', user, name='user'),
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
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'),
]
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,
- we have an access_token in the response body
- a refreshtoken as a HttpOnly cookie
- 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.
A successful entry would generate the following response -
if you hit http://127.0.0.1:8000/users/login
on postman you'll be able to see the following-
that we have successfully generated two cookies
- a refreshtoken as a HttpOnly cookie
- 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)
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
You might then face the following error -
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 -
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})
Important points to note here are,
to generate a new access token the request expects -
- A cookie that contains a valid refresh_token
- A header 'X-CSRFTOKEN' with a valid csrf token
- 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-
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 )
The source code - https://dev.to/a_atalla/django-rest-framework-custom-jwt-authentication-5n5 (have modified it a little in this post)
Difference between Session Based and Token Based Mechanisms - https://dev.to/thecodearcher/what-really-is-the-difference-between-session-and-token-based-authentication-2o39
The request-response cycle in Django - https://medium.com/@ksarthak4ever/django-request-response-cycle-2626e9e8606e
CSRF Attacks - https://dev.to/_smellycode/csrf-in-action-21n3
XSS Attacks - https://dev.to/kmistele/xss-what-it-is-how-it-works-and-how-to-prevent-it-589o
Why JWT in Django? https://stackoverflow.com/questions/31600497/django-drf-token-based-authentication-vs-json-web-token
Top comments (4)
Thanks Bhavana. This post really helped me. I noticed one thing;
in the generate_refresh_token method following programmer will need to modify
the settings.SECRET_KEY to settings.REFRESH_TOKEN_SECRET.
Thanks again Bhavana for this well explained post.
Hello. Well explained post. Thanks for it! Exactly what i was needed. Just a little question. Why do we need the csrf cookie? I think the access token + refresh token in http only cookie will be enough.
Really appreciate your code flow its my goodness if i get a chance to learn something from you.