Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ local_settings.py
db.sqlite3
db.sqlite3-journal

jwttest/
manage.py

# Flask stuff:
instance/
.webassets-cache
Expand Down
25 changes: 25 additions & 0 deletions docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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``.

5 changes: 5 additions & 0 deletions rest_framework_simplejwt/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
119 changes: 119 additions & 0 deletions rest_framework_simplejwt/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
119 changes: 119 additions & 0 deletions tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import timedelta
from http import cookies
from importlib import reload
from unittest.mock import patch

Expand Down Expand Up @@ -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)
5 changes: 5 additions & 0 deletions tests/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]