From 8dd263d000f6f51553bc3d36af8188515cf73b3c Mon Sep 17 00:00:00 2001 From: Niyaz Batyrshin Date: Sat, 7 Sep 2019 20:52:52 +0300 Subject: [PATCH 1/5] 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'), ] From 8661f5b3c15d8041fe57a1832918f7fd3519b6b7 Mon Sep 17 00:00:00 2001 From: Niyaz Batyrshin Date: Wed, 2 Oct 2019 21:34:22 +0300 Subject: [PATCH 2/5] CSRF validation for JWT cookie based authentication --- README.rst | 7 +++ rest_framework_simplejwt/authentication.py | 25 ++++++++- rest_framework_simplejwt/views.py | 16 +++--- tests/test_integration.py | 59 +++++++++++++++++----- tests/views.py | 3 ++ 5 files changed, 88 insertions(+), 22 deletions(-) diff --git a/README.rst b/README.rst index fc8ab334b..777a799cf 100644 --- a/README.rst +++ b/README.rst @@ -144,6 +144,8 @@ To enable cookie storage set ``AUTH_COOKIE`` name: '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``: @@ -155,6 +157,11 @@ In your root ``urls.py`` file (or any other url config), include routes for ... ] +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. + Settings -------- diff --git a/rest_framework_simplejwt/authentication.py b/rest_framework_simplejwt/authentication.py index 68c3a856b..f58bc106c 100644 --- a/rest_framework_simplejwt/authentication.py +++ b/rest_framework_simplejwt/authentication.py @@ -1,5 +1,6 @@ from django.utils.translation import ugettext_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 .models import TokenUser @@ -17,6 +18,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) + reason = check.process_view(request, None, (), {}) + if reason: + # CSRF failed, bail with explicit error message + raise exceptions.PermissionDenied('CSRF Failed: %s' % reason) + + class JWTAuthentication(authentication.BaseAuthentication): """ An authentication plugin that authenticates requests through a JSON web @@ -38,7 +52,14 @@ def authenticate(self, request): 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( diff --git a/rest_framework_simplejwt/views.py b/rest_framework_simplejwt/views.py index 3187abbb0..7241d3b11 100644 --- a/rest_framework_simplejwt/views.py +++ b/rest_framework_simplejwt/views.py @@ -1,5 +1,6 @@ from datetime import datetime +from django.middleware import csrf from django.utils.translation import ugettext_lazy as _ from rest_framework import generics, status from rest_framework.exceptions import NotAuthenticated @@ -39,11 +40,12 @@ def post(self, request, *args, **kwargs): response = Response(serializer.validated_data, status=status.HTTP_200_OK) if api_settings.AUTH_COOKIE: - response = self.set_cookies(response, serializer.validated_data) + csrf.get_token(self.request) + response = self.set_auth_cookies(response, serializer.validated_data) return response - def set_cookies(self, response, data): + def set_auth_cookies(self, response, data): return response @@ -66,7 +68,7 @@ def extract_token_from_cookie(self, request): request.data['refresh'] = token return request - def set_cookies(self, response, data): + def set_auth_cookies(self, response, data): expires = self.get_refresh_token_expiration() response.set_cookie( api_settings.AUTH_COOKIE, data['access'], @@ -130,7 +132,7 @@ def extract_token_from_cookie(self, request): request.data['token'] = token return request - def set_cookies(self, response, data): + def set_auth_cookies(self, response, data): response.set_cookie( api_settings.AUTH_COOKIE, data['token'], expires=datetime.now() + api_settings.REFRESH_TOKEN_LIFETIME, @@ -181,16 +183,18 @@ class TokenCookieDeleteView(APIView): Deletes httpOnly auth cookies. Used as logout view while using AUTH_COOKIE """ + authentication_classes = () + permission_classes = () def post(self, request): response = Response({}) if api_settings.AUTH_COOKIE: - self.delete_cookies(response) + self.delete_auth_cookies(response) return response - def delete_cookies(self, response): + def delete_auth_cookies(self, response): response.delete_cookie( api_settings.AUTH_COOKIE, domain=api_settings.AUTH_COOKIE_DOMAIN, diff --git a/tests/test_integration.py b/tests/test_integration.py index f45cc1d54..e574f6b2f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,5 +1,8 @@ from datetime import timedelta +from django.conf import settings +from django.middleware.csrf import REASON_BAD_TOKEN + from rest_framework_simplejwt.compat import reverse from rest_framework_simplejwt.settings import api_settings from rest_framework_simplejwt.state import User @@ -86,7 +89,8 @@ def test_user_can_get_sliding_token_and_use_it(self): 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( + client = self.client_class(enforce_csrf_checks=True) + res = client.post( reverse('token_obtain_sliding'), data={ User.USERNAME_FIELD: self.username, @@ -94,28 +98,41 @@ def test_user_can_get_access_refresh_and_delete_sliding_token_cookies_and_use_th }, ) - res = self.view_get() + csrf_cookie = res.wsgi_request.environ['CSRF_COOKIE'] + client.cookies.load({settings.CSRF_COOKIE_NAME: csrf_cookie}) + + res = client.get(reverse(self.view_name)) self.assertEqual(res.status_code, 200) self.assertEqual(res.data['foo'], 'bar') - res = self.client.post( + res = client.post(reverse(self.view_name)) + + self.assertEqual(res.status_code, 403) + self.assertTrue(REASON_BAD_TOKEN in res.data['detail']) + + res = client.post(reverse(self.view_name), **{settings.CSRF_HEADER_NAME: csrf_cookie}) + + self.assertEqual(res.status_code, 200) + self.assertEqual(res.data['foo'], 'bar') + + res = client.post( reverse('token_refresh_sliding'), ) - res = self.view_get() + res = client.get(reverse(self.view_name)) self.assertEqual(res.status_code, 200) self.assertEqual(res.data['foo'], 'bar') - res = self.client.post( + res = client.post( reverse('token_delete'), ) - res = self.view_get() + res = client.get(reverse(self.view_name)) self.assertEqual(res.status_code, 401) - res = self.client.post( + res = client.post( reverse('token_refresh_sliding'), ) self.assertEqual(res.status_code, 401) @@ -157,7 +174,8 @@ def test_user_can_get_access_and_refresh_tokens_and_use_them(self): 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( + client = self.client_class(enforce_csrf_checks=True) + res = client.post( reverse('token_obtain_pair'), data={ User.USERNAME_FIELD: self.username, @@ -165,28 +183,41 @@ def test_user_can_get_access_refresh_and_delete_token_cookies_and_use_them(self) }, ) - res = self.view_get() + csrf_cookie = res.wsgi_request.environ['CSRF_COOKIE'] + client.cookies.load({settings.CSRF_COOKIE_NAME: csrf_cookie}) + + res = client.get(reverse(self.view_name)) self.assertEqual(res.status_code, 200) self.assertEqual(res.data['foo'], 'bar') - res = self.client.post( + res = client.post(reverse(self.view_name)) + + self.assertEqual(res.status_code, 403) + self.assertTrue(REASON_BAD_TOKEN in res.data['detail']) + + res = client.post(reverse(self.view_name), **{settings.CSRF_HEADER_NAME: csrf_cookie}) + + self.assertEqual(res.status_code, 200) + self.assertEqual(res.data['foo'], 'bar') + + res = client.post( reverse('token_refresh'), ) - res = self.view_get() + res = client.get(reverse(self.view_name)) self.assertEqual(res.status_code, 200) self.assertEqual(res.data['foo'], 'bar') - res = self.client.post( + res = client.post( reverse('token_delete'), ) - res = self.view_get() + res = client.get(reverse(self.view_name)) self.assertEqual(res.status_code, 401) - res = self.client.post( + res = client.post( reverse('token_refresh'), ) self.assertEqual(res.status_code, 401) diff --git a/tests/views.py b/tests/views.py index c8a85ced6..54f951657 100644 --- a/tests/views.py +++ b/tests/views.py @@ -12,5 +12,8 @@ class TestView(APIView): def get(self, request): return Response({'foo': 'bar'}) + def post(self, request): + return Response({'foo': 'bar'}) + test_view = TestView.as_view() From 34431cfda828b54056cc63f50f2926466e6fb5b7 Mon Sep 17 00:00:00 2001 From: Niyaz Batyrshin Date: Wed, 2 Oct 2019 22:37:15 +0300 Subject: [PATCH 3/5] add curl example for X-CSRFToken usage --- README.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.rst b/README.rst index 777a799cf..c0cb8f027 100644 --- a/README.rst +++ b/README.rst @@ -162,6 +162,25 @@ set when issuing the JWT authentication cookie. This works in conjunction with d 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. +.. 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 csrftoken cookie value from cookies.txt file 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 -------- From 078224ee606352d1b72fe498f91c61bac8d2ed33 Mon Sep 17 00:00:00 2001 From: Niyaz Batyrshin Date: Thu, 5 Mar 2020 00:07:54 +0300 Subject: [PATCH 4/5] Fixes bug: web (using cookie) and mobile (using request.data) did not work at the same time when AUTH_COOKIE is enabled --- README.rst | 7 ++++++- rest_framework_simplejwt/views.py | 24 ++++++++++++++---------- tests/test_integration.py | 15 +++++++++++++-- tests/test_views.py | 10 +++++----- 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/README.rst b/README.rst index 3216c1aee..3033ebf5e 100644 --- a/README.rst +++ b/README.rst @@ -162,6 +162,11 @@ set when issuing the JWT authentication cookie. This works in conjunction with d 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 \ @@ -171,7 +176,7 @@ setting) on every requests via unsafe methods, such as POST, PUT, PATCH and DELE --cookie-jar cookies.txt \ http://localhost:8000/api/token/ -Copy csrftoken cookie value from cookies.txt file to X-CSRFToken header +Copy returned csrftoken cookie value from cookies.txt file (while using curl) to X-CSRFToken header: .. code-block:: bash diff --git a/rest_framework_simplejwt/views.py b/rest_framework_simplejwt/views.py index 7241d3b11..aaf3e7a9b 100644 --- a/rest_framework_simplejwt/views.py +++ b/rest_framework_simplejwt/views.py @@ -61,11 +61,13 @@ def post(self, 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 + """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): @@ -125,11 +127,13 @@ def get_refresh_token_expiration(self): 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 + """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): diff --git a/tests/test_integration.py b/tests/test_integration.py index e574f6b2f..2f4b260c2 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -86,8 +86,14 @@ 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_sliding_token_and_use_it_when_auth_cookie_enabled(self): + # should also work with tokens in request.data when AUTH_COOKIE is enabled + with override_api_settings(AUTH_COOKIE='authorization', + AUTH_TOKEN_CLASSES=('rest_framework_simplejwt.tokens.SlidingToken',)): + self.test_user_can_get_sliding_token_and_use_it() + def test_user_can_get_access_refresh_and_delete_sliding_token_cookies_and_use_them(self): - with override_api_settings(AUTH_COOKIE='Authorization', + with override_api_settings(AUTH_COOKIE='authorization', AUTH_TOKEN_CLASSES=('rest_framework_simplejwt.tokens.SlidingToken',)): client = self.client_class(enforce_csrf_checks=True) res = client.post( @@ -172,8 +178,13 @@ 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_and_refresh_tokens_and_use_them_when_auth_cookie_enabled(self): + # should also work with tokens in request.data when AUTH_COOKIE is enabled + with override_api_settings(AUTH_COOKIE='authorization', ): + self.test_user_can_get_access_and_refresh_tokens_and_use_them() + def test_user_can_get_access_refresh_and_delete_token_cookies_and_use_them(self): - with override_api_settings(AUTH_COOKIE='Authorization', ): + with override_api_settings(AUTH_COOKIE='authorization', ): client = self.client_class(enforce_csrf_checks=True) res = client.post( reverse('token_obtain_pair'), diff --git a/tests/test_views.py b/tests/test_views.py index eeb108b0e..2adc6f0ea 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -67,14 +67,14 @@ def test_success(self): self.assertIn('access', res.data) self.assertIn('refresh', res.data) - with override_api_settings(AUTH_COOKIE='Authorization'): + 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) + self.assertIn('authorization', res.cookies) + self.assertIn('authorization_refresh', res.cookies) class TestTokenRefreshView(APIViewTestCase): @@ -181,13 +181,13 @@ def test_success(self): self.assertEqual(res.status_code, 200) self.assertIn('token', res.data) - with override_api_settings(AUTH_COOKIE='Authorization'): + 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', res.cookies) class TestTokenRefreshSlidingView(APIViewTestCase): From 56222b8e23e1031f18953a195016c64ac2e94923 Mon Sep 17 00:00:00 2001 From: Niyaz Batyrshin Date: Thu, 5 Mar 2020 00:41:39 +0300 Subject: [PATCH 5/5] `token_refresh_view_name` view attr, makes it possible to change refresh view name if required Set `token_refresh_view_name` argument in `as_view` method of TokenObtainPairView and TokenCookieDeleteView in urlpatterns to change refresh token view name Example: ```python urlpatterns = [ path('api/token/', TokenObtainPairView.as_view(token_refresh_view_name='jwt_refresh'), name='token_obtain_pair'), path('api/token/delete/', TokenCookieDeleteView.as_view(token_refresh_view_name='jwt_refresh'), name='token_delete'), ] ``` # where TokenRefreshView url name is `jwt_refresh` --- rest_framework_simplejwt/views.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/rest_framework_simplejwt/views.py b/rest_framework_simplejwt/views.py index aaf3e7a9b..351bd5977 100644 --- a/rest_framework_simplejwt/views.py +++ b/rest_framework_simplejwt/views.py @@ -60,6 +60,8 @@ def post(self, 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: @@ -86,7 +88,7 @@ def set_auth_cookies(self, response, data): '{}_refresh'.format(api_settings.AUTH_COOKIE), data['refresh'], expires=expires, domain=None, - path=reverse('token_refresh'), + path=reverse(self.token_refresh_view_name), secure=api_settings.AUTH_COOKIE_SECURE or None, httponly=True, samesite='Strict', @@ -187,6 +189,7 @@ 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 = () @@ -207,7 +210,7 @@ def delete_auth_cookies(self, response): response.delete_cookie( '{}_refresh'.format(api_settings.AUTH_COOKIE), domain=None, - path=reverse('token_refresh'), + path=reverse(self.token_refresh_view_name), )