Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JWT httpOnly cookie storage. #157

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,64 @@ refresh token to obtain another access token:
...
{"access":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiY29sZF9zdHVmZiI6IuKYgyIsImV4cCI6MTIzNTY3LCJqdGkiOiJjNzE4ZTVkNjgzZWQ0NTQyYTU0NWJkM2VmMGI0ZGQ0ZSJ9.ekxRxgb9OKmHkfy-zs1Ro_xs1eMLXiR17dIDBVxeT-w"}

JWT httpOnly cookie storage
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'll need to merge with master and move this documentation to where the rest of the documentation is now

---------------------------

JWT tokens can be stored in cookies for web applications. Cookies, when used
with the HttpOnly cookie flag, are not accessible through JavaScript, and are
immune to XSS. To guarantee the cookie is sent only over HTTPS, set Secure
cookie flag.

To enable cookie storage set ``AUTH_COOKIE`` name:

.. code-block:: python

SIMPLE_JWT = {
'AUTH_COOKIE': 'Authorization',
}

Since httpOnly cookies are not accessible via JavaScript, cookies must be deleted by a server request to log out.

In your root ``urls.py`` file (or any other url config), include routes for
``TokenCookieDeleteView``:

.. code-block:: python

urlpatterns = [
...
path('api/token/delete/', TokenCookieDeleteView.as_view(), name='token_delete'),
...
]

To prevent Cross-Site Request Forgery, the ``csrftoken`` (specified by ``CSRF_COOKIE_NAME`` setting) cookie will also be
set when issuing the JWT authentication cookie. This works in conjunction with django csrf middleware. The cookie
contains another token which should be included in the ``X-CSRFToken`` header (as specified by the ``CSRF_HEADER_NAME``
setting) on every requests via unsafe methods, such as POST, PUT, PATCH and DELETE.

Usage
-----

To verify that cookies are working, you can use curl to issue a couple of test requests:

.. code-block:: bash

curl \
-X POST \
-H "Content-Type: application/json" \
-d '{"username": "davidattenborough", "password": "boatymcboatface"}' \
--cookie-jar cookies.txt \
http://localhost:8000/api/token/

Copy returned csrftoken cookie value from cookies.txt file (while using curl) to X-CSRFToken header:

.. code-block:: bash

curl \
-X POST \
-H "X-CSRFToken: fUgacGTt55Cq8Gzp9lz1rxSxa9CoSB9mYPIGgne35FuVC2g7doAjQSupZQkFh4H9" \
--cookie ./cookies.txt \
http://localhost:8000/api/some-protected-view/

Settings
--------

Expand Down Expand Up @@ -170,6 +228,12 @@ Some of Simple JWT's behavior can be customized through settings variables in
'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),

'AUTH_COOKIE': None,
'AUTH_COOKIE_DOMAIN': None,
'AUTH_COOKIE_SECURE': False,
'AUTH_COOKIE_PATH': '/',
'AUTH_COOKIE_SAMESITE': 'Lax',
}

Above, the default values for these settings are shown.
Expand Down Expand Up @@ -301,6 +365,24 @@ SLIDING_TOKEN_REFRESH_EXP_CLAIM
The claim name that is used to store the expiration time of a sliding token's
refresh period. More about this in the "Sliding tokens" section below.

AUTH_COOKIE
Cookie name. Enables auth cookies if value is set.

AUTH_COOKIE_DOMAIN
A string like "example.com", or None for standard domain cookie.

AUTH_COOKIE_SECURE
Whether to use a secure cookie for the session cookie. If this is set to
True, the cookie will be marked as secure, which means browsers may ensure
that the cookie is only sent under an HTTPS connection.

AUTH_COOKIE_PATH
The path of the auth cookie.

AUTH_COOKIE_SAMESITE
Whether to set the flag restricting cookie leaks on cross-site requests.
This can be 'Lax', 'Strict', or None to disable the flag.

Customizing token claims
------------------------

Expand Down
34 changes: 29 additions & 5 deletions rest_framework_simplejwt/authentication.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.utils.translation import gettext_lazy as _
from rest_framework import HTTP_HEADER_ENCODING, authentication
from rest_framework import HTTP_HEADER_ENCODING, authentication, exceptions
from rest_framework.authentication import CSRFCheck

from .exceptions import AuthenticationFailed, InvalidToken, TokenError
from .settings import api_settings
Expand All @@ -16,6 +17,19 @@
)


def enforce_csrf(request):
"""
Enforce CSRF validation.
"""
check = CSRFCheck()
# populates request.META['CSRF_COOKIE'], which is used in process_view()
check.process_request(request)
Copy link

@rodrigondec rodrigondec Mar 10, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behaviour

On this line the request.META.['CSRF_COOKIE'] is set from CSRF_COOKIE.

Later on line csrf_token = request.META.get('CSRF_COOKIE') it's set on the method CSRFCheck.process_view().

Later request_csrf_token is set from request.META.get(settings.CSRF_HEADER_NAME, '') (header X-CSRFToken).

After we have these csrf tokens we compare request_csrf_token against csrf_token.

Problem?

My request_csrf_token is None if I don't send the header X-CSRFToken. So I need to send the header X-CSRFToken from my frontend. To do so my frontend application need to access the CSRF cookie.

Is this supposed to happen?

Or should my backend set request.META.get(settings.CSRF_HEADER_NAME, '') from CSRF_COOKIE?

Note

When my frontend and backend are running from different servers my frontend doesn't have access to CSRF_COOKIE

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, try setting: CSRF_COOKIE_HTTPONLY = True

reason = check.process_view(request, None, (), {})
if reason:
# CSRF failed, bail with explicit error message
raise exceptions.PermissionDenied('CSRF Failed: %s' % reason)
NiyazNz marked this conversation as resolved.
Show resolved Hide resolved


class JWTAuthentication(authentication.BaseAuthentication):
"""
An authentication plugin that authenticates requests through a JSON web
Expand All @@ -26,15 +40,25 @@ class JWTAuthentication(authentication.BaseAuthentication):
def authenticate(self, request):
header = self.get_header(request)
if header is None:
return None

raw_token = self.get_raw_token(header)
if not api_settings.AUTH_COOKIE:
return None
else:
raw_token = request.COOKIES.get(api_settings.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)

return self.get_user(validated_token), validated_token
user = self.get_user(validated_token)
if not user or not user.is_active:
return None

if api_settings.AUTH_COOKIE:
enforce_csrf(request)

return user, validated_token

def authenticate_header(self, request):
return '{0} realm="{1}"'.format(
Expand Down
12 changes: 12 additions & 0 deletions rest_framework_simplejwt/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@
'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),

# Cookie name. Enables cookies if value is set.
'AUTH_COOKIE': None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this should have a better name, more verbose like AUTH_COOKIE_NAME like the CSRF_COOKIE_NAME.

# A string like "example.com", or None for standard domain cookie.
'AUTH_COOKIE_DOMAIN': None,
# Whether the auth cookies should be secure (https:// only).
'AUTH_COOKIE_SECURE': False,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you're working with CSRF, you should instead use this Django setting instead of False: CSRF_COOKIE_SECURE. Same for the rest of the settings. Set the defaults to the CSRF counterparts.

# The path of the auth cookie.
'AUTH_COOKIE_PATH': '/',
# Whether to set the flag restricting cookie leaks on cross-site requests.
# This can be 'Lax', 'Strict', or None to disable the flag.
'AUTH_COOKIE_SAMESITE': 'Lax',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly to the AUTH_COOKIE_SECURE: CSRF_COOKIE_SAMESITE

}

IMPORT_STRINGS = (
Expand Down
141 changes: 136 additions & 5 deletions rest_framework_simplejwt/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
from datetime import datetime

from django.middleware import csrf
from django.utils.translation import ugettext_lazy as _
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ugettext_lazy deprecated in favor of gettext_lazy.

from rest_framework import generics, status
from rest_framework.exceptions import NotAuthenticated
from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.views import APIView

from rest_framework_simplejwt.settings import api_settings
from rest_framework_simplejwt.tokens import RefreshToken
from . import serializers
from .authentication import AUTH_HEADER_TYPES
from .exceptions import InvalidToken, TokenError
Expand Down Expand Up @@ -28,10 +37,69 @@ def post(self, request, *args, **kwargs):
except TokenError as e:
raise InvalidToken(e.args[0])

return Response(serializer.validated_data, status=status.HTTP_200_OK)
response = Response(serializer.validated_data, status=status.HTTP_200_OK)

if api_settings.AUTH_COOKIE:
csrf.get_token(self.request)
response = self.set_auth_cookies(response, serializer.validated_data)

return response
NiyazNz marked this conversation as resolved.
Show resolved Hide resolved

def set_auth_cookies(self, response, data):
return response


class TokenObtainPairView(TokenViewBase):
class TokenRefreshViewBase(TokenViewBase):
def extract_token_from_cookie(self, request):
return request

def post(self, request, *args, **kwargs):
if api_settings.AUTH_COOKIE:
request = self.extract_token_from_cookie(request)
return super().post(request, *args, **kwargs)


class TokenCookieViewMixin:
token_refresh_view_name = 'token_refresh'

def extract_token_from_cookie(self, request):
"""Extracts token from cookie and sets it in request.data as it would be sent by the user"""
if not request.data:
token = request.COOKIES.get('{}_refresh'.format(api_settings.AUTH_COOKIE))
if not token:
raise NotAuthenticated(detail=_('Refresh cookie not set. Try to authenticate first.'))
else:
request.data['refresh'] = token
return request

def set_auth_cookies(self, response, data):
expires = self.get_refresh_token_expiration()
response.set_cookie(
api_settings.AUTH_COOKIE, data['access'],
expires=expires,
domain=api_settings.AUTH_COOKIE_DOMAIN,
path=api_settings.AUTH_COOKIE_PATH,
secure=api_settings.AUTH_COOKIE_SECURE or None,
httponly=True,
samesite=api_settings.AUTH_COOKIE_SAMESITE,
)
if 'refresh' in data:
response.set_cookie(
'{}_refresh'.format(api_settings.AUTH_COOKIE), data['refresh'],
expires=expires,
domain=None,
path=reverse(self.token_refresh_view_name),
secure=api_settings.AUTH_COOKIE_SECURE or None,
httponly=True,
samesite='Strict',
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you set the samesite atribute to 'strict'?
I have my client and my API on two distinct domains so I can't refresh my tokens.
I think you may use api_settings.AUTH_COOKIE_SAMESIT constant to set the attribute.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about the domain=None on the creation of {AUTH_COOKIE}_refresh?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here use domain.com for the front-end and api.domain.com for the backend. i rather have the cookie in domain.com

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, I'd prefer this to not be hardcoded. Current implementation means backend api.domain.com and frontend domain.com won't work, which a lot of people use.

I would prefer this:
domain=None -> domain=api_settings.AUTH_COOKIE_DOMAIN
samesite='Strict' -> domain=api_settings.AUTH_COOKIE_SAMESITE

or at least a separate setting for the refresh token so it's configurable.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SameSite='strict' Slightly concerning if a dev creates a subdomain (e.g. Instagram's misc. webpages).

)
return response

def get_refresh_token_expiration(self):
return datetime.now() + api_settings.REFRESH_TOKEN_LIFETIME
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is datetime.now() the expected behavior? The server could be anywhere, the client can be anywhere, so perhaps using django.utils.timezone.now would be better since it will always use UTC server side. The method set_cookie expects a datetime object in UTC: https://docs.djangoproject.com/en/3.0/ref/request-response/#django.http.HttpResponse.set_cookie

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idk if L124 would be a problem then.



class TokenObtainPairView(TokenCookieViewMixin, TokenViewBase):
"""
Takes a set of user credentials and returns an access and refresh JSON web
token pair to prove the authentication of those credentials.
Expand All @@ -42,18 +110,48 @@ class TokenObtainPairView(TokenViewBase):
token_obtain_pair = TokenObtainPairView.as_view()


class TokenRefreshView(TokenViewBase):
class TokenRefreshView(TokenCookieViewMixin, TokenRefreshViewBase):
"""
Takes a refresh type JSON web token and returns an access type JSON web
token if the refresh token is valid.
"""
serializer_class = serializers.TokenRefreshSerializer

def get_refresh_token_expiration(self):
if api_settings.ROTATE_REFRESH_TOKENS:
return super().get_refresh_token_expiration()
token = RefreshToken(self.request.data['refresh'])
return datetime.fromtimestamp(token.payload['exp'])


token_refresh = TokenRefreshView.as_view()


class TokenObtainSlidingView(TokenViewBase):
class SlidingTokenCookieViewMixin:
def extract_token_from_cookie(self, request):
"""Extracts token from cookie and sets it in request.data as it would be sent by the user"""
if not request.data:
token = request.COOKIES.get(api_settings.AUTH_COOKIE)
if not token:
raise NotAuthenticated(detail=_('Refresh cookie not set. Try to authenticate first.'))
else:
request.data['token'] = token
return request

def set_auth_cookies(self, response, data):
response.set_cookie(
api_settings.AUTH_COOKIE, data['token'],
expires=datetime.now() + api_settings.REFRESH_TOKEN_LIFETIME,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, should probably use timezone.now() instead of datetime.now()

domain=api_settings.AUTH_COOKIE_DOMAIN,
path=api_settings.AUTH_COOKIE_PATH,
secure=api_settings.AUTH_COOKIE_SECURE or None,
httponly=True,
samesite=api_settings.AUTH_COOKIE_SAMESITE,
)
return response


class TokenObtainSlidingView(SlidingTokenCookieViewMixin, TokenViewBase):
"""
Takes a set of user credentials and returns a sliding JSON web token to
prove the authentication of those credentials.
Expand All @@ -64,7 +162,7 @@ class TokenObtainSlidingView(TokenViewBase):
token_obtain_sliding = TokenObtainSlidingView.as_view()


class TokenRefreshSlidingView(TokenViewBase):
class TokenRefreshSlidingView(SlidingTokenCookieViewMixin, TokenRefreshViewBase):
"""
Takes a sliding JSON web token and returns a new, refreshed version if the
token's refresh period has not expired.
Expand All @@ -84,3 +182,36 @@ class TokenVerifyView(TokenViewBase):


token_verify = TokenVerifyView.as_view()


class TokenCookieDeleteView(APIView):
"""
Deletes httpOnly auth cookies.
Used as logout view while using AUTH_COOKIE
"""
token_refresh_view_name = 'token_refresh'
authentication_classes = ()
permission_classes = ()

def post(self, request):
response = Response({})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to have an empty dict inside the response? Shouldn't it just be Response()?


if api_settings.AUTH_COOKIE:
self.delete_auth_cookies(response)

return response

def delete_auth_cookies(self, response):
response.delete_cookie(
api_settings.AUTH_COOKIE,
domain=api_settings.AUTH_COOKIE_DOMAIN,
path=api_settings.AUTH_COOKIE_PATH
)
response.delete_cookie(
'{}_refresh'.format(api_settings.AUTH_COOKIE),
domain=None,
path=reverse(self.token_refresh_view_name),
)

NiyazNz marked this conversation as resolved.
Show resolved Hide resolved

token_delete = TokenCookieDeleteView.as_view()
Loading