Jalal
Jalal

Reputation: 366

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

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?

Upvotes: 12

Views: 18618

Answers (3)

far1din
far1din

Reputation: 101

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! :)

Upvotes: 6

Pradip Kachhadiya
Pradip Kachhadiya

Reputation: 2235

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..

Upvotes: 17

Jay Patel
Jay Patel

Reputation: 2451

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

Upvotes: 9

Related Questions