From 8dd263d000f6f51553bc3d36af8188515cf73b3c Mon Sep 17 00:00:00 2001 From: Niyaz Batyrshin Date: Sat, 7 Sep 2019 20:52:52 +0300 Subject: [PATCH] Add JWT httpOnly cookie storage. refs davesque/django-rest-framework-simplejwt#71 --- README.rst | 51 ++++++++ rest_framework_simplejwt/authentication.py | 9 +- rest_framework_simplejwt/settings.py | 12 ++ rest_framework_simplejwt/views.py | 130 ++++++++++++++++++++- tests/test_integration.py | 74 +++++++++++- tests/test_views.py | 19 ++- tests/urls.py | 2 + 7 files changed, 287 insertions(+), 10 deletions(-) diff --git a/README.rst b/README.rst index 88e128853..fc8ab334b 100644 --- a/README.rst +++ b/README.rst @@ -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 -------- @@ -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. @@ -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 ------------------------ diff --git a/rest_framework_simplejwt/authentication.py b/rest_framework_simplejwt/authentication.py index 8154c5180..68c3a856b 100644 --- a/rest_framework_simplejwt/authentication.py +++ b/rest_framework_simplejwt/authentication.py @@ -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 diff --git a/rest_framework_simplejwt/settings.py b/rest_framework_simplejwt/settings.py index 2b8974368..6b07dc89b 100644 --- a/rest_framework_simplejwt/settings.py +++ b/rest_framework_simplejwt/settings.py @@ -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 = ( diff --git a/rest_framework_simplejwt/views.py b/rest_framework_simplejwt/views.py index fec1edcac..3187abbb0 100644 --- a/rest_framework_simplejwt/views.py +++ b/rest_framework_simplejwt/views.py @@ -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 @@ -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. @@ -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. @@ -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. @@ -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() diff --git a/tests/test_integration.py b/tests/test_integration.py index 7d2db2edc..f45cc1d54 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -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 @@ -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'), @@ -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) diff --git a/tests/test_views.py b/tests/test_views.py index 3c05568c0..eeb108b0e 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -10,7 +10,7 @@ aware_utcnow, datetime_from_epoch, datetime_to_epoch, ) -from .utils import APIViewTestCase +from .utils import APIViewTestCase, override_api_settings class TestTokenObtainPairView(APIViewTestCase): @@ -67,6 +67,15 @@ def test_success(self): self.assertIn('access', res.data) self.assertIn('refresh', res.data) + with override_api_settings(AUTH_COOKIE='Authorization'): + res = self.view_post(data={ + User.USERNAME_FIELD: self.username, + 'password': self.password, + }) + self.assertEqual(res.status_code, 200) + self.assertIn('Authorization', res.cookies) + self.assertIn('Authorization_refresh', res.cookies) + class TestTokenRefreshView(APIViewTestCase): view_name = 'token_refresh' @@ -172,6 +181,14 @@ def test_success(self): self.assertEqual(res.status_code, 200) self.assertIn('token', res.data) + with override_api_settings(AUTH_COOKIE='Authorization'): + res = self.view_post(data={ + User.USERNAME_FIELD: self.username, + 'password': self.password, + }) + self.assertEqual(res.status_code, 200) + self.assertIn('Authorization', res.cookies) + class TestTokenRefreshSlidingView(APIViewTestCase): view_name = 'token_refresh_sliding' diff --git a/tests/urls.py b/tests/urls.py index 04f105641..4c4d2dcdf 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -13,5 +13,7 @@ url(r'^token/verify/$', jwt_views.token_verify, name='token_verify'), + url(r'^token/delete/$', jwt_views.token_delete, name='token_delete'), + url(r'^test-view/$', views.test_view, name='test_view'), ]