How to store JWT tokens in HttpOnly cookies with DRF djangorestframework-simplejwt package?
O

3

11

I've been using djangorestframework-simplejwt for a while and now I want to store the JWT in the cookies (instead of localstorage or front-end states) so that every request that the client makes, contains the token.

So did some research on it and the most relevant result I found was this stackoverflow question, in which the author is using djangorestframework-jwt package which has a pre-configured setting for cookies called JWT_AUTH_COOKIE. So figured switching to that package but then ended up finding out that the package is pretty much dead.

Although there is a fork for the djangorestframework-jwt that is recommended to use instead, I was wondering is there anyway to set the JWTs in HttpOnly cookies with the djagnorestframework_simplejwt itself?

Opus answered 17/2, 2021 at 18:35 Comment(0)
H
16

With httponly cookie flag and CSRF protection follow this code.

Both side very useful in mobile app and webapp..

urls.py:

...
path('login/',LoginView.as_view(),name = "login"),
...

view.py:

from rest_framework_simplejwt.tokens import RefreshToken
from django.middleware import csrf

def get_tokens_for_user(user):
    refresh = RefreshToken.for_user(user)
        
    return {
        'refresh': str(refresh),
        'access': str(refresh.access_token),
    }

class LoginView(APIView):
    def post(self, request, format=None):
        data = request.data
        response = Response()        
        username = data.get('username', None)
        password = data.get('password', None)
        user = authenticate(username=username, password=password)
        if user is not None:
            if user.is_active:
                data = get_tokens_for_user(user)
                response.set_cookie(
                                    key = settings.SIMPLE_JWT['AUTH_COOKIE'], 
                                    value = data["access"],
                                    expires = settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'],
                                    secure = settings.SIMPLE_JWT['AUTH_COOKIE_SECURE'],
                                    httponly = settings.SIMPLE_JWT['AUTH_COOKIE_HTTP_ONLY'],
                                    samesite = settings.SIMPLE_JWT['AUTH_COOKIE_SAMESITE']
                                        )
                csrf.get_token(request)
                email_template = render_to_string('login_success.html',{"username":user.username})    
                login = EmailMultiAlternatives(
                    "Successfully Login", 
                    "Successfully Login",
                    settings.EMAIL_HOST_USER, 
                    [user.email],
                )
                login.attach_alternative(email_template, 'text/html')
                login.send()
                response.data = {"Success" : "Login successfully","data":data}
                
                return response
            else:
                return Response({"No active" : "This account is not active!!"},status=status.HTTP_404_NOT_FOUND)
        else:
            return Response({"Invalid" : "Invalid username or password!!"},status=status.HTTP_404_NOT_FOUND)

authenticate.py:

from rest_framework_simplejwt.authentication import JWTAuthentication
from django.conf import settings

from rest_framework.authentication import CSRFCheck
from rest_framework import exceptions

def enforce_csrf(request):
    """
    Enforce CSRF validation.
    """
    check = CSRFCheck()
    # populates request.META['CSRF_COOKIE'], which is used in process_view()
    check.process_request(request)
    reason = check.process_view(request, None, (), {})
    if reason:
        # CSRF failed, bail with explicit error message
        raise exceptions.PermissionDenied('CSRF Failed: %s' % reason)

class CustomAuthentication(JWTAuthentication):
    
    def authenticate(self, request):
        header = self.get_header(request)
        
        if header is None:
            raw_token = request.COOKIES.get(settings.SIMPLE_JWT['AUTH_COOKIE']) or None
        else:
            raw_token = self.get_raw_token(header)
        if raw_token is None:
            return None

        validated_token = self.get_validated_token(raw_token)
        enforce_csrf(request)
        return self.get_user(validated_token), validated_token

settings.py:

....
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'authentication.authenticate.CustomAuthentication',
    ),
}

SIMPLE_JWT = {
.....
'AUTH_COOKIE': 'access_token',  # Cookie name. Enables cookies if value is set.
'AUTH_COOKIE_DOMAIN': None,     # A string like "example.com", or None for standard domain cookie.
'AUTH_COOKIE_SECURE': False,    # Whether the auth cookies should be secure (https:// only).
'AUTH_COOKIE_HTTP_ONLY' : True, # Http only cookie flag.It's not fetch by javascript.
'AUTH_COOKIE_PATH': '/',        # The path of the auth cookie.
'AUTH_COOKIE_SAMESITE': 'Lax',  # Whether to set the flag restricting cookie leaks on cross-site requests.
                                # This can be 'Lax', 'Strict', or None to disable the flag.
}

--------- OR ------------

By using middleware.py:

How to authenticate by using middleware

Must :

withCredentials is True from both side..

Any doubt please comment..

Hekate answered 17/2, 2021 at 18:58 Comment(16)
in views.py, seconds line, I don't know where to get the RefreshTokenGrant from. rest_framework_simplejwt.tokens has RefreshToken, did you mean that? Or is it a custom method?Opus
Yes, RefreshToken.When I was copy this code at that time my VS code editor snippet change it.I don't know when was this change occurs.Tnx @Jalal.Hekate
Oh good mate, tnq. Now in views.py again, where it says user = authenticate(username=username, password=password), by authenticate are we calling the serializer or are we calling that custom athenticate method we made in the other module from the CustomAuthentication class?Opus
user = authenticate(username=username, password=password) This authenticate is coming from from django.contrib.auth import authenticate and inside CustomAuthentication is inherit fromJWTAuthentication.It's check only JWT token valid or not.If this token valid then user can act particular task.Otherwise raise authentication credential not provided....Hekate
@Pradip Hey, instead of this function RefreshToken for generating the token for the user, is it possible to add a custom method ? or is this the safest way. ThanksTackle
@Tackle Which type of method? because RefreshToken function already return access and refresh token..If you want Customizing token claims then use this.Hekate
@Pradip, Thank you, that was what I meant actually. Can I ask you one more doubt? like I have seen lots of mixed responses on using jwt authentication. so is it really safe to use?Tackle
@Tackle yes ask your all doubts related to jwt..Yes it safe when your site run on https and put your jwt-secret-key in safe place. If you build money or bank transaction related site then never store your token at client side. sorry for my bad English.Hekate
Is anyone else running into an issue using this where it will always claim the CSRF token missing or incorrect? (Even though it does show in request.META['CSRF_COOKIE'])Boldt
Is this method restricted to HTTPS?Hematuria
@Pradip came across this solution and I was wondering is there a way to implement the refresh endpoint so I don't have to pass a token in the body as currently implemented in the simplejwt package?Limemann
@Limemann yes, you won't need, bcz it handled by our CustomAuthenticationHekate
@Pradip Thanks for the response. Also came across this solution . You can look at it for reference. Thought it was kinda helpfulLimemann
In the login view, what is the point of the email template thing?Deteriorate
render HTML content in mail..click here @U.WattHekate
Am I missing something? We cannot instantiate CSRFCheck without a get_response argument.Askja
D
9

You can do the following to store refresh token in the httpOnly cookie:

Add this to views.py:

# views.py
from rest_framework_simplejwt.views import TokenRefreshView, TokenObtainPairView
from rest_framework_simplejwt.serializers import TokenRefreshSerializer
from rest_framework_simplejwt.exceptions import InvalidToken

class CookieTokenRefreshSerializer(TokenRefreshSerializer):
    refresh = None
    def validate(self, attrs):
        attrs['refresh'] = self.context['request'].COOKIES.get('refresh_token')
        if attrs['refresh']:
            return super().validate(attrs)
        else:
            raise InvalidToken('No valid token found in cookie \'refresh_token\'')

class CookieTokenObtainPairView(TokenObtainPairView):
  def finalize_response(self, request, response, *args, **kwargs):
    if response.data.get('refresh'):
        cookie_max_age = 3600 * 24 * 14 # 14 days
        response.set_cookie('refresh_token', response.data['refresh'], max_age=cookie_max_age, httponly=True )
        del response.data['refresh']
    return super().finalize_response(request, response, *args, **kwargs)

class CookieTokenRefreshView(TokenRefreshView):
    def finalize_response(self, request, response, *args, **kwargs):
        if response.data.get('refresh'):
            cookie_max_age = 3600 * 24 * 14 # 14 days
            response.set_cookie('refresh_token', response.data['refresh'], max_age=cookie_max_age, httponly=True )
            del response.data['refresh']
        return super().finalize_response(request, response, *args, **kwargs)
    serializer_class = CookieTokenRefreshSerializer

Change the urls in url.py to use those views for token obtaining and refreshing:

# url.py
from .views import CookieTokenRefreshView, CookieTokenObtainPairView # Import the above views
# [...]
urlpatterns = [
    path('auth/token/', CookieTokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('auth/token/refresh/', CookieTokenRefreshView.as_view(), name='token_refresh'),
    # [...]
]

Check your CORS settings if it doesn't work as expected: maybe you have to set sameSite and secure in set_cookie

Workflow - obtain token pair using credentials

  1. POST /auth/token with valid credentials
  2. In the response body you'll notice that only the 'access' key is set
  3. The 'refresh' key has been moved to the httpOnly cookie named 'refresh_token'

Workflow - obtain access (and optional refresh) token using refresh token

  1. POST /auth/token/refresh with the cookie set from the previous workflow, the body can be empty

  2. In the response body you'll notice that only the 'access' key is set

  3. If you have set ROTATE_REFRESH_TOKENS, the httpOnly cookie 'refresh_token' contains a new refresh token

Ref: https://github.com/jazzband/djangorestframework-simplejwt/issues/71#issuecomment-762927394

Darreldarrell answered 11/8, 2021 at 15:35 Comment(2)
Why not also set the access token as a http only cookie and create a custom authentication that gets the access cookie from the headers and validates it?Wellrounded
Or is it because the client has to check the expiry of the access token?Wellrounded
C
6

I've searched everywhere and here is what I found. I will try to explain the whole process from setting the cookie as well as retreiving the cookie. I know I am late, but like me, there must be other people trying to find an answer. Please correct me if I have done something wrong.

SETTING THE COOKIE

In the django documentations under project configurations, you will find a that they use TokenObtainPairView.as_view() to obtain the token. We will modify the TokenObtainPairView in a separate views file, call it MyTokenObtainPairView and import it in. See code below.

# urls.py
from django.urls import path
from .views import MyTokenObtainPairView

urlpatterns = [
    path("token/", MyTokenObtainPairView.as_view(), name="token_obtain_pair"),
]
# views.py
from rest_framework_simplejwt.views import TokenObtainPairView


class MyTokenObtainPairView(TokenObtainPairView):
    def post(self, request, *args, **kwargs):
        response = super().post(request, *args, **kwargs)
        token = response.data["access"]
        response.set_cookie("pick_a_name_you_like_for_the_cookie", token, httponly=True)
        return response

You can test this in postman. Assuming you did this from the root and use localhost, the endpoint should be something like: http://localhost:8000/token/. You should run a POST request with login credentials (usually username and password). Now, if you use postman, you should see that there is a cookie called pick_a_name_you_like_for_the_cookie

-> Read more (stack)

-> Read more (Documentation) - This is more for customizing the token claims.

-

RETREIVING TOKEN By default the simplejwt will look in the header for the access token. Therefore, you will not be able to retreive user by using user = request.user or add permissions such as permission_classes = [IsAuthenticated] or @permission_classes([IsAuthenticated]). See docs.

This is because the default authentication classes are set as:

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": (
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    )
}

We will need to copy and modify the JWTAuthentication class. Create a file in the root directory called whatever you want. In this case custom_auth.py. Copy everything from rest_framework_simplejwt/authentication.py. You will find this file by entering the virtual environment or going directly to where pip installed rest_framework_simplejwt.

Now, assuming you've copied everything, change the following codes in custom_auth.py from:

from .exceptions import AuthenticationFailed, InvalidToken, TokenError
from .settings import api_settings

to

from rest_framework_simplejwt.exceptions import AuthenticationFailed, InvalidToken, TokenError
from rest_framework_simplejwt.settings import api_settings

Now inside the class called JWTAuthentication you will need to change the authentication function to something like:

class JWTAuthentication(authentication.BaseAuthentication):
    """
    An authentication plugin that authenticates requests through a JSON web
    token provided in a request header.
    """

    www_authenticate_realm = "api"
    media_type = "application/json"

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.user_model = get_user_model()

    def authenticate(self, request):
        cookie = request.COOKIES.get("pick_a_name_you_like_for_the_cookie")
        raw_token = cookie.encode(HTTP_HEADER_ENCODING)
        validated_token = self.get_validated_token(raw_token)

        return self.get_user(validated_token), validated_token
    ...

You should add some validations etc. as to what should be done if there is no cookie etc. Probably something like:

    if cookie is None:
        return None

Customize the errorhandling as you would like. Finally go back to settings.py and replace:

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": (
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    )
}

with:

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": (
        "custom_auth.JWTAuthentication",
    )
}

Please let me know if I leave any vulnerabilities or if I could've done something different! :)

Composition answered 7/11, 2022 at 21:38 Comment(1)
p.s. Looks like there is no need to fully copy JWTAuthentication class. We can just inherit it, and redefine authenticate method.Acidophil

© 2022 - 2024 — McMap. All rights reserved.