Skip to content

Commit

Permalink
Add JWT httpOnly cookie storage.
Browse files Browse the repository at this point in the history
  • Loading branch information
NiyazNz committed Sep 7, 2019
1 parent 89cdeaa commit 8dd263d
Show file tree
Hide file tree
Showing 7 changed files with 287 additions and 10 deletions.
51 changes: 51 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,33 @@ refresh token to obtain another access token:
...
{"access":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiY29sZF9zdHVmZiI6IuKYgyIsImV4cCI6MTIzNTY3LCJqdGkiOiJjNzE4ZTVkNjgzZWQ0NTQyYTU0NWJkM2VmMGI0ZGQ0ZSJ9.ekxRxgb9OKmHkfy-zs1Ro_xs1eMLXiR17dIDBVxeT-w"}
JWT httpOnly cookie storage
---------------------------

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',
}
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'),
...
]
Settings
--------

Expand Down Expand Up @@ -164,6 +191,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 @@ -285,6 +318,24 @@ SLIDING_TOKEN_REFRESH_EXP_CLAIM
The claim name that is used to store the exipration 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
9 changes: 6 additions & 3 deletions rest_framework_simplejwt/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ 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

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 @@ -31,6 +31,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,
# 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,
# 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',
}

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

from django.utils.translation import ugettext_lazy as _
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 +36,64 @@ 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:
response = self.set_cookies(response, serializer.validated_data)

return response

def set_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:
def extract_token_from_cookie(self, request):
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_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('token_refresh'),
secure=api_settings.AUTH_COOKIE_SECURE or None,
httponly=True,
samesite='Strict',
)
return response

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


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 +104,46 @@ 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):
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_cookies(self, response, data):
response.set_cookie(
api_settings.AUTH_COOKIE, data['token'],
expires=datetime.now() + api_settings.REFRESH_TOKEN_LIFETIME,
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 +154,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 +174,33 @@ class TokenVerifyView(TokenViewBase):


token_verify = TokenVerifyView.as_view()


class TokenCookieDeleteView(APIView):
"""
Deletes httpOnly auth cookies.
Used as logout view while using AUTH_COOKIE
"""

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

if api_settings.AUTH_COOKIE:
self.delete_cookies(response)

return response

def delete_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('token_refresh'),
)


token_delete = TokenCookieDeleteView.as_view()
74 changes: 73 additions & 1 deletion tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from rest_framework_simplejwt.settings import api_settings
from rest_framework_simplejwt.state import User
from rest_framework_simplejwt.tokens import AccessToken

from .utils import APIViewTestCase, override_api_settings


Expand Down Expand Up @@ -84,6 +83,43 @@ def test_user_can_get_sliding_token_and_use_it(self):
self.assertEqual(res.status_code, 200)
self.assertEqual(res.data['foo'], 'bar')

def test_user_can_get_access_refresh_and_delete_sliding_token_cookies_and_use_them(self):
with override_api_settings(AUTH_COOKIE='Authorization',
AUTH_TOKEN_CLASSES=('rest_framework_simplejwt.tokens.SlidingToken',)):
res = self.client.post(
reverse('token_obtain_sliding'),
data={
User.USERNAME_FIELD: self.username,
'password': self.password,
},
)

res = self.view_get()

self.assertEqual(res.status_code, 200)
self.assertEqual(res.data['foo'], 'bar')

res = self.client.post(
reverse('token_refresh_sliding'),
)

res = self.view_get()

self.assertEqual(res.status_code, 200)
self.assertEqual(res.data['foo'], 'bar')

res = self.client.post(
reverse('token_delete'),
)

res = self.view_get()
self.assertEqual(res.status_code, 401)

res = self.client.post(
reverse('token_refresh_sliding'),
)
self.assertEqual(res.status_code, 401)

def test_user_can_get_access_and_refresh_tokens_and_use_them(self):
res = self.client.post(
reverse('token_obtain_pair'),
Expand Down Expand Up @@ -118,3 +154,39 @@ def test_user_can_get_access_and_refresh_tokens_and_use_them(self):

self.assertEqual(res.status_code, 200)
self.assertEqual(res.data['foo'], 'bar')

def test_user_can_get_access_refresh_and_delete_token_cookies_and_use_them(self):
with override_api_settings(AUTH_COOKIE='Authorization', ):
res = self.client.post(
reverse('token_obtain_pair'),
data={
User.USERNAME_FIELD: self.username,
'password': self.password,
},
)

res = self.view_get()

self.assertEqual(res.status_code, 200)
self.assertEqual(res.data['foo'], 'bar')

res = self.client.post(
reverse('token_refresh'),
)

res = self.view_get()

self.assertEqual(res.status_code, 200)
self.assertEqual(res.data['foo'], 'bar')

res = self.client.post(
reverse('token_delete'),
)

res = self.view_get()
self.assertEqual(res.status_code, 401)

res = self.client.post(
reverse('token_refresh'),
)
self.assertEqual(res.status_code, 401)
Loading

0 comments on commit 8dd263d

Please sign in to comment.