diff --git a/.gitignore b/.gitignore index 8e807af60..6a6043be9 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,9 @@ local_settings.py db.sqlite3 db.sqlite3-journal +jwttest/ +manage.py + # Flask stuff: instance/ .webassets-cache diff --git a/docs/settings.rst b/docs/settings.rst index c0c674963..4d9f3fea2 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -41,6 +41,10 @@ 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), + + 'TOKEN_COOKIE_PATH': '/', + 'TOKEN_COOKIE_DOMAIN': None, + 'TOKEN_COOKIE_SAMESITE': None, } Above, the default values for these settings are shown. @@ -236,3 +240,24 @@ More about this in the "Sliding tokens" section below. 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. + +``TOKEN_COOKIE_PATH`` +----------------------------------- + +The value for the ``Path`` attribute of the token carrying cookie. Defaults to ``'/'``. + +``TOKEN_COOKIE_DOMAIN`` +----------------------------------- + +The value for the ``Domain`` attribute of the token carrying cookie. Defaults to ``None``. + +``TOKEN_COOKIE_SAMESITE`` +----------------------------------- + +The value for the ``SameSite`` attribute of the token carrying cookie. Defaults to ``'Lax'``. + +``TOKEN_COOKIE_SECURE`` +----------------------------------- + +The value for the ``Secure` attribute of the token carrying cookie. Defaults to ``False``. + diff --git a/rest_framework_simplejwt/settings.py b/rest_framework_simplejwt/settings.py index cf751e69f..2ea738de6 100644 --- a/rest_framework_simplejwt/settings.py +++ b/rest_framework_simplejwt/settings.py @@ -37,6 +37,11 @@ 'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp', 'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5), 'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1), + + 'TOKEN_COOKIE_PATH': '/', + 'TOKEN_COOKIE_DOMAIN': None, + 'TOKEN_COOKIE_SAMESITE': 'Lax', + 'TOKEN_COOKIE_SECURE': False, } IMPORT_STRINGS = ( diff --git a/rest_framework_simplejwt/views.py b/rest_framework_simplejwt/views.py index fec1edcac..6214a2738 100644 --- a/rest_framework_simplejwt/views.py +++ b/rest_framework_simplejwt/views.py @@ -4,6 +4,9 @@ from . import serializers from .authentication import AUTH_HEADER_TYPES from .exceptions import InvalidToken, TokenError +from .settings import api_settings +from .tokens import RefreshToken, UntypedToken +from .utils import datetime_from_epoch class TokenViewBase(generics.GenericAPIView): @@ -84,3 +87,119 @@ class TokenVerifyView(TokenViewBase): token_verify = TokenVerifyView.as_view() + + +## +## Views for cookie based token (only refresh token in cookie) +## + +class TokenCookieBaseView(generics.GenericAPIView): + permission_classes = () + authentication_classes = () + + serializer_class = None + + www_authenticate_realm = 'api' + + def get_authenticate_header(self, request): + return '{0} realm="{1}"'.format( + AUTH_HEADER_TYPES[0], + self.www_authenticate_realm, + ) + + +class TokenPairCookieBaseView(TokenCookieBaseView): + def use_serializer(self, serializer): + try: + serializer.is_valid(raise_exception=True) + except TokenError as e: + raise InvalidToken(e.args[0]) + + validated_data = serializer.validated_data + + if 'refresh' in validated_data: + refresh_token = RefreshToken(validated_data.pop('refresh')) + else: + refresh_token = None + + response = Response(validated_data, status=status.HTTP_200_OK) + + if not refresh_token == None: + response.set_cookie( + key = 'refresh', + value = str(refresh_token), + expires = datetime_from_epoch(refresh_token.payload['exp']), + path = api_settings.TOKEN_COOKIE_PATH, + domain = api_settings.TOKEN_COOKIE_DOMAIN, + samesite = api_settings.TOKEN_COOKIE_SAMESITE, + secure = api_settings.TOKEN_COOKIE_SECURE, + httponly = True + ) + + return response + + +class TokenObtainPairCookieView(TokenPairCookieBaseView): + """ + Takes a set of user credentials and returns an access and refresh JSON web + token pair to prove the authentication of those credentials. + + Refresh token is sent via httponly cookie. This view needs CSRF protection. + """ + + serializer_class = serializers.TokenObtainPairSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data = request.data) + + return self.use_serializer(serializer) + + +token_obtain_pair_cookie = TokenObtainPairCookieView.as_view() + + +class TokenRefreshCookieView(TokenPairCookieBaseView): + """ + Takes a refresh type JSON web token and returns an access type JSON web + token if the refresh token is valid. + + If refresh token is rotated, it is sent via httponly cookie. This view needs CSRF protection. + """ + serializer_class = serializers.TokenRefreshSerializer + + def post(self, request, *args, **kwargs): + try: + refresh = request.COOKIES['refresh'] + except KeyError: + refresh = '' + + serializer = self.get_serializer(data = {'refresh': refresh}) + + return self.use_serializer(serializer) + + +token_refresh_cookie = TokenRefreshCookieView.as_view() + + +class TokenVerifyCookieView(TokenCookieBaseView): + """ + Takes a token residing in a cookie and indicates if it is valid. This view provides no + information about a token's fitness for a particular use. + """ + def post(self, request, *args, **kwargs): + try: + refresh = request.COOKIES['refresh'] + except KeyError: + refresh = '' + + serializer = serializers.TokenVerifySerializer(data = {'token': refresh}) + + try: + serializer.is_valid(raise_exception=True) + except TokenError as e: + raise InvalidToken(e.args[0]) + + return Response(serializer.validated_data, status=status.HTTP_200_OK) + + +token_verify_cookie = TokenVerifyCookieView.as_view() \ No newline at end of file diff --git a/tests/test_views.py b/tests/test_views.py index 1c6c48a33..b32d14f0d 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,4 +1,5 @@ from datetime import timedelta +from http import cookies from importlib import reload from unittest.mock import patch @@ -335,3 +336,121 @@ def test_it_should_ignore_token_type(self): res = self.view_post(data={'token': str(token)}) self.assertEqual(res.status_code, 200) self.assertEqual(len(res.data), 0) + + +class TestTokenObtainPairCookieView(TestTokenObtainPairView): + view_name = 'token_obtain_pair_cookie' + + def test_success(self): + res = self.view_post(data={ + User.USERNAME_FIELD: self.username, + 'password': self.password, + }) + self.assertEqual(res.status_code, 200) + self.assertIn('access', res.data) + self.assertIn('refresh', res.cookies) + + +class TestTokenRefreshCookieView(TestTokenRefreshView): + view_name = 'token_refresh_cookie' + + def test_it_should_return_401_if_token_invalid(self): + token = RefreshToken() + del token['exp'] + + client_cookies = cookies.SimpleCookie() + client_cookies['refresh'] = str(token) + client_cookies['refresh']['path'] = '/api/cookie/' + self.client.cookies = client_cookies + + res = self.view_post(data={}) + self.assertEqual(res.status_code, 401) + self.assertEqual(res.data['code'], 'token_not_valid') + + token.set_exp(lifetime=-timedelta(seconds=1)) + + client_cookies = cookies.SimpleCookie() + client_cookies['refresh'] = str(token) + client_cookies['refresh']['path'] = '/api/cookie/' + self.client.cookies = client_cookies + + res = self.view_post(data={}) + self.assertEqual(res.status_code, 401) + self.assertEqual(res.data['code'], 'token_not_valid') + + def test_it_should_return_access_token_if_everything_ok(self): + refresh = RefreshToken() + refresh['test_claim'] = 'arst' + + # View returns 200 + now = aware_utcnow() - api_settings.ACCESS_TOKEN_LIFETIME / 2 + + with patch('rest_framework_simplejwt.tokens.aware_utcnow') as fake_aware_utcnow: + fake_aware_utcnow.return_value = now + + client_cookies = cookies.SimpleCookie() + client_cookies['refresh'] = str(refresh) + client_cookies['refresh']['path'] = '/api/cookie/' + self.client.cookies = client_cookies + + res = self.view_post(data={}) + + self.assertEqual(res.status_code, 200) + + access = AccessToken(res.data['access']) + + self.assertEqual(refresh['test_claim'], access['test_claim']) + self.assertEqual(access['exp'], datetime_to_epoch(now + api_settings.ACCESS_TOKEN_LIFETIME)) + + +class TestTokenVerifyCookieView(TestTokenVerifyView): + view_name = 'token_verify_cookie' + + def test_it_should_return_401_if_token_invalid(self): + token = SlidingToken() + del token['exp'] + + client_cookies = cookies.SimpleCookie() + client_cookies['refresh'] = str(token) + client_cookies['refresh']['path'] = '/api/cookie/' + self.client.cookies = client_cookies + + res = self.view_post(data={}) + self.assertEqual(res.status_code, 401) + self.assertEqual(res.data['code'], 'token_not_valid') + + token.set_exp(lifetime=-timedelta(seconds=1)) + + client_cookies = cookies.SimpleCookie() + client_cookies['refresh'] = str(token) + client_cookies['refresh']['path'] = '/api/cookie/' + self.client.cookies = client_cookies + + res = self.view_post(data={}) + self.assertEqual(res.status_code, 401) + self.assertEqual(res.data['code'], 'token_not_valid') + + def test_it_should_return_200_if_everything_okay(self): + token = RefreshToken() + + client_cookies = cookies.SimpleCookie() + client_cookies['refresh'] = str(token) + client_cookies['refresh']['path'] = '/api/cookie/' + self.client.cookies = client_cookies + + res = self.view_post(data={}) + self.assertEqual(res.status_code, 200) + self.assertEqual(len(res.data), 0) + + def test_it_should_ignore_token_type(self): + token = RefreshToken() + token[api_settings.TOKEN_TYPE_CLAIM] = 'fake_type' + + client_cookies = cookies.SimpleCookie() + client_cookies['refresh'] = str(token) + client_cookies['refresh']['path'] = '/api/cookie/' + self.client.cookies = client_cookies + + res = self.view_post(data={}) + self.assertEqual(res.status_code, 200) + self.assertEqual(len(res.data), 0) diff --git a/tests/urls.py b/tests/urls.py index 14e8a0b99..1fe5519bf 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -14,4 +14,9 @@ re_path(r'^token/verify/$', jwt_views.token_verify, name='token_verify'), re_path(r'^test-view/$', views.test_view, name='test_view'), + + re_path(r'^api/cookietoken/pair/$', jwt_views.token_obtain_pair_cookie, name='token_obtain_pair_cookie'), + re_path(r'^api/cookie/cookietoken/refresh/$', jwt_views.token_refresh_cookie, name='token_refresh_cookie'), + + re_path(r'^api/cookie/cookietoken/verify/$', jwt_views.token_verify_cookie, name='token_verify_cookie'), ]