From 81c6b9ee17f235de7ba5644179d5fc1e279c636f Mon Sep 17 00:00:00 2001 From: Niyaz Batyrshin Date: Sat, 7 Sep 2019 20:52:52 +0300 Subject: [PATCH 01/20] Add JWT httpOnly cookie storage. refs davesque/django-rest-framework-simplejwt#71 --- README.rst | 559 +++++++++++++++++++++ rest_framework_simplejwt/authentication.py | 9 +- rest_framework_simplejwt/settings.py | 12 + rest_framework_simplejwt/views.py | 130 ++++- tests/test_integration.py | 74 ++- tests/urls.py | 1 + 6 files changed, 776 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index 1c3fdf830..5caea94c4 100644 --- a/README.rst +++ b/README.rst @@ -17,6 +17,565 @@ Abstract Simple JWT is a JSON Web Token authentication plugin for the `Django REST Framework `__. +------------------------------------------------------------------------------- + +Simple JWT provides a JSON Web Token authentication backend for the Django REST +Framework. It aims to provide an out-of-the-box solution for JWT +authentication which avoids some of the common pitfalls of the JWT +specification. Assuming users of the library don't extensively and invasively +subclass everything, Simple JWT's behavior shouldn't be surprising. Settings +variable defaults should be safe. + +Requirements +------------ + +* Python (3.6, 3.7) +* Django (2.0, 2.1, 2.2) +* Django REST Framework (3.8, 3.9, 3.10) + +These are the officially supported python and package versions. Other versions +will probably work. You're free to modify the tox config and see what is +possible. + +Installation +------------ + +Simple JWT can be installed with pip:: + + pip install djangorestframework_simplejwt + +Then, your django project must be configured to use the library. In +``settings.py``, add +``rest_framework_simplejwt.authentication.JWTAuthentication`` to the list of +authentication classes: + +.. code-block:: python + + REST_FRAMEWORK = { + ... + 'DEFAULT_AUTHENTICATION_CLASSES': ( + ... + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ) + ... + } + +Also, in your root ``urls.py`` file (or any other url config), include routes +for Simple JWT's ``TokenObtainPairView`` and ``TokenRefreshView`` views: + +.. code-block:: python + + from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + ) + + urlpatterns = [ + ... + url(r'^api/token/$', TokenObtainPairView.as_view(), name='token_obtain_pair'), + url(r'^api/token/refresh/$', TokenRefreshView.as_view(), name='token_refresh'), + ... + ] + +You can also include a route for Simple JWT's ``TokenVerifyView`` if you wish to +allow API users to verify HMAC-signed tokens without having access to your +signing key: + +.. code-block:: python + + urlpatterns = [ + ... + url(r'^api/token/verify/$', TokenVerifyView.as_view(), name='token_verify'), + ... + ] + +Usage +----- + +To verify that Simple JWT is working, you can use curl to issue a couple of +test requests: + +.. code-block:: bash + + curl \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"username": "davidattenborough", "password": "boatymcboatface"}' \ + http://localhost:8000/api/token/ + + ... + { + "access":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiY29sZF9zdHVmZiI6IuKYgyIsImV4cCI6MTIzNDU2LCJqdGkiOiJmZDJmOWQ1ZTFhN2M0MmU4OTQ5MzVlMzYyYmNhOGJjYSJ9.NHlztMGER7UADHZJlxNG0WSi22a2KaYSfd1S-AuT7lU", + "refresh":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImNvbGRfc3R1ZmYiOiLimIMiLCJleHAiOjIzNDU2NywianRpIjoiZGUxMmY0ZTY3MDY4NDI3ODg5ZjE1YWMyNzcwZGEwNTEifQ.aEoAYkSJjoWH1boshQAaTkf8G3yn0kapko6HFRt7Rh4" + } + +You can use the returned access token to prove authentication for a protected +view: + +.. code-block:: bash + + curl \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiY29sZF9zdHVmZiI6IuKYgyIsImV4cCI6MTIzNDU2LCJqdGkiOiJmZDJmOWQ1ZTFhN2M0MmU4OTQ5MzVlMzYyYmNhOGJjYSJ9.NHlztMGER7UADHZJlxNG0WSi22a2KaYSfd1S-AuT7lU" \ + http://localhost:8000/api/some-protected-view/ + +When this short-lived access token expires, you can use the longer-lived +refresh token to obtain another access token: + +.. code-block:: bash + + curl \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"refresh":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImNvbGRfc3R1ZmYiOiLimIMiLCJleHAiOjIzNDU2NywianRpIjoiZGUxMmY0ZTY3MDY4NDI3ODg5ZjE1YWMyNzcwZGEwNTEifQ.aEoAYkSJjoWH1boshQAaTkf8G3yn0kapko6HFRt7Rh4"}' \ + http://localhost:8000/api/token/refresh/ + + ... + {"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 +-------- + +Some of Simple JWT's behavior can be customized through settings variables in +``settings.py``: + +.. code-block:: python + + # Django project settings.py + + from datetime import timedelta + + ... + + SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'ROTATE_REFRESH_TOKENS': False, + 'BLACKLIST_AFTER_ROTATION': True, + + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': settings.SECRET_KEY, + 'VERIFYING_KEY': None, + + 'AUTH_HEADER_TYPES': ('Bearer',), + 'USER_ID_FIELD': 'id', + 'USER_ID_CLAIM': 'user_id', + + 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), + 'TOKEN_TYPE_CLAIM': 'token_type', + + 'JTI_CLAIM': 'jti', + + '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. + +------------------------------------------------------------------------------- + +ACCESS_TOKEN_LIFETIME + A ``datetime.timedelta`` object which specifies how long access tokens are + valid. This ``timedelta`` value is added to the current UTC time during + token generation to obtain the token's default "exp" claim value. + +REFRESH_TOKEN_LIFETIME + A ``datetime.timedelta`` object which specifies how long refresh tokens are + valid. This ``timedelta`` value is added to the current UTC time during + token generation to obtain the token's default "exp" claim value. + +ROTATE_REFRESH_TOKENS + When set to ``True``, if a refresh token is submitted to the + ``TokenRefreshView``, a new refresh token will be returned along with the new + access token. This new refresh token will be supplied via a "refresh" key in + the JSON response. New refresh tokens will have a renewed expiration time + which is determined by adding the timedelta in the ``REFRESH_TOKEN_LIFETIME`` + setting to the current time when the request is made. If the blacklist app + is in use and the ``BLACKLIST_AFTER_ROTATION`` setting is set to ``True``, + refresh tokens submitted to the refresh view will be added to the blacklist. + +BLACKLIST_AFTER_ROTATION + When set to ``True``, causes refresh tokens submitted to the + ``TokenRefreshView`` to be added to the blacklist if the blacklist app is in + use and the ``ROTATE_REFRESH_TOKENS`` setting is set to ``True``. + +ALGORITHM + The algorithm from the PyJWT library which will be used to perform + signing/verification operations on tokens. To use symmetric HMAC signing and + verification, the following algorithms may be used: ``'HS256'``, ``'HS384'``, + ``'HS512'``. When an HMAC algorithm is chosen, the ``SIGNING_KEY`` setting + will be used as both the signing key and the verifying key. In that case, + the ``VERIFYING_KEY`` setting will be ignored. To use asymmetric RSA signing + and verification, the following algorithms may be used: ``'RS256'``, + ``'RS384'``, ``'RS512'``. When an RSA algorithm is chosen, the + ``SIGNING_KEY`` setting must be set to a string which contains an RSA private + key. Likewise, the ``VERIFYING_KEY`` setting must be set to a string which + contains an RSA public key. + +SIGNING_KEY + The signing key which is used to sign the content of generated tokens. For + HMAC signing, this should be a random string with at least as many bits of + data as is required by the signing protocol. For RSA signing, this + should be a string which contains an RSA private key which is 2048 bits or + longer. Since Simple JWT defaults to using 256-bit HMAC signing, the + ``SIGNING_KEY`` setting defaults to the value of the ``SECRET_KEY`` setting + for your django project. Although this is the most reasonable default that + Simple JWT can provide, it is recommended that developers change this setting + to a value which is independent from the django project secret key. This + will make changing the signing key used for tokens easier in the event that + it is compromised. + +VERIFYING_KEY + The verifying key which is used to verify the content of generated tokens. + If an HMAC algorithm has been specified by the ``ALGORITHM`` setting, the + ``VERIFYING_KEY`` setting will be ignored and the value of the + ``SIGNING_KEY`` setting will be used. If an RSA algorithm has been specified + by the ``ALGORITHM`` setting, the ``VERIFYING_KEY`` setting must be set to a + string which contains an RSA public key. + +AUTH_HEADER_TYPES + The authorization header type(s) that will be accepted for views that require + authentication. For example, a value of ``'Bearer'`` means that views + requiring authentication would look for a header with the following format: + ``Authorization: Bearer ``. This setting may also contain a list or + tuple of possible header types (e.g. ``('Bearer', 'JWT')``). If a list or + tuple is used in this way, and authentication fails, the first item in the + collection will be used to build the "WWW-Authenticate" header in the + response. + +USER_ID_FIELD + The database field from the user model that will be included in generated + tokens to identify users. It is recommended that the value of this setting + specifies a field which does not normally change once its initial value is + chosen. For example, specifying a "username" or "email" field would be a + poor choice since an account's username or email might change depending on + how account management in a given service is designed. This could allow a + new account to be created with an old username while an existing token is + still valid which uses that username as a user identifier. + +USER_ID_CLAIM + The claim in generated tokens which will be used to store user identifiers. + For example, a setting value of ``'user_id'`` would mean generated tokens + include a "user_id" claim that contains the user's identifier. + +AUTH_TOKEN_CLASSES + A list of dot paths to classes which specify the types of token that are + allowed to prove authentication. More about this in the "Token types" + section below. + +TOKEN_TYPE_CLAIM + The claim name that is used to store a token's type. More about this in the + "Token types" section below. + +JTI_CLAIM + The claim name that is used to store a token's unique identifier. This + identifier is used to identify revoked tokens in the blacklist app. It may + be necessary in some cases to use another claim besides the default "jti" + claim to store such a value. + +SLIDING_TOKEN_LIFETIME + A ``datetime.timedelta`` object which specifies how long sliding tokens are + valid to prove authentication. This ``timedelta`` value is added to the + current UTC time during token generation to obtain the token's default "exp" + claim value. More about this in the "Sliding tokens" section below. + +SLIDING_TOKEN_REFRESH_LIFETIME + A ``datetime.timedelta`` object which specifies how long sliding tokens are + valid to be refreshed. This ``timedelta`` value is added to the current UTC + time during token generation to obtain the token's default "exp" claim value. + More about this in the "Sliding tokens" section below. + +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 +------------------------ + +If you wish to customize the claims contained in web tokens which are generated +by the ``TokenObtainPairView`` and ``TokenObtainSlidingView`` views, create a +subclass for the desired view as well as a subclass for its corresponding +serializer. Here's an example of how to customize the claims in tokens +generated by the ``TokenObtainPairView``: + +.. code-block:: python + + from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + from rest_framework_simplejwt.views import TokenObtainPairView + + class MyTokenObtainPairSerializer(TokenObtainPairSerializer): + @classmethod + def get_token(cls, user): + token = super().get_token(user) + + # Add custom claims + token['name'] = user.name + # ... + + return token + + class MyTokenObtainPairView(TokenObtainPairView): + serializer_class = MyTokenObtainPairSerializer + +Note that the example above will cause the customized claims to be present in +both refresh *and* access tokens which are generated by the view. This follows +from the fact that the ``get_token`` method above produces the *refresh* token +for the view, which is in turn used to generate the view's access token. + +As with the standard token views, you'll also need to include a url route to +your subclassed view. + +Creating tokens manually +------------------------ + +Sometimes, you may wish to manually create a token for a user. This could be +done as follows: + +.. code-block:: python + + from rest_framework_simplejwt.tokens import RefreshToken + + def get_tokens_for_user(user): + refresh = RefreshToken.for_user(user) + + return { + 'refresh': str(refresh), + 'access': str(refresh.access_token), + } + +The above function ``get_tokens_for_user`` will return the serialized +representations of new refresh and access tokens for the given user. In +general, a token for any subclass of ``rest_framework_simplejwt.tokens.Token`` +can be created in this way. + +Token types +----------- + +Simple JWT provides two different token types which can be used to prove +authentication. In a token's payload, its type can be identified by the value +of its token type claim, which is "token_type" by default. This may have a +value of "access", "sliding", or "refresh" however refresh tokens are not +considered valid for authentication at this time. The claim name used to store +the type can be customized by changing the ``TOKEN_TYPE_CLAIM`` setting. + +By default, Simple JWT expects an "access" token to prove authentication. The +allowed auth token types are determined by the value of the +``AUTH_TOKEN_CLASSES`` setting. This setting contains a list of dot paths to +token classes. It includes the +``'rest_framework_simplejwt.tokens.AccessToken'`` dot path by default but may +also include the ``'rest_framework_simplejwt.tokens.SlidingToken'`` dot path. +Either or both of those dot paths may be present in the list of auth token +classes. If they are both present, then both of those token types may be used +to prove authentication. + +Sliding tokens +-------------- + +Sliding tokens offer a more convenient experience to users of tokens with the +trade-offs of being less secure and, in the case that the blacklist app is +being used, less performant. A sliding token is one which contains both an +expiration claim and a refresh expiration claim. As long as the timestamp in a +sliding token's expiration claim has not passed, it can be used to prove +authentication. Additionally, as long as the timestamp in its refresh +expiration claim has not passed, it may also be submitted to a refresh view to +get another copy of itself with a renewed expiration claim. + +If you want to use sliding tokens, change the ``AUTH_TOKEN_CLASSES`` setting to +``('rest_framework_simplejwt.tokens.SlidingToken',)``. (Alternatively, the +``AUTH_TOKEN_CLASSES`` setting may include dot paths to both the +``AccessToken`` and ``SlidingToken`` token classes in the +``rest_framework_simplejwt.tokens`` module if you want to allow both token +types to be used for authentication.) + +Also, include urls for the sliding token specific ``TokenObtainSlidingView`` +and ``TokenRefreshSlidingView`` views along side or in place of urls for the +access token specific ``TokenObtainPairView`` and ``TokenRefreshView`` views: + +.. code-block:: python + + from rest_framework_simplejwt.views import ( + TokenObtainSlidingView, + TokenRefreshSlidingView, + ) + + urlpatterns = [ + ... + url(r'^api/token/$', TokenObtainSlidingView.as_view(), name='token_obtain'), + url(r'^api/token/refresh/$', TokenRefreshSlidingView.as_view(), name='token_refresh'), + ... + ] + +Be aware that, if you are using the blacklist app, Simple JWT will validate all +sliding tokens against the blacklist for each authenticated request. This will +reduce the performance of authenticated API views. + +Blacklist app +------------- + +Simple JWT includes an app that provides token blacklist functionality. To use +this app, include it in your list of installed apps in ``settings.py``: + +.. code-block:: python + + # Django project settings.py + + ... + + INSTALLED_APPS = ( + ... + 'rest_framework_simplejwt.token_blacklist', + ... + } + +Also, make sure to run ``python manage.py migrate`` to run the app's +migrations. + +If the blacklist app is detected in ``INSTALLED_APPS``, Simple JWT will add any +generated refresh or sliding tokens to a list of outstanding tokens. It will +also check that any refresh or sliding token does not appear in a blacklist of +tokens before it considers it as valid. + +The Simple JWT blacklist app implements its outstanding and blacklisted token +lists using two models: ``OutstandingToken`` and ``BlacklistedToken``. Model +admins are defined for both of these models. To add a token to the blacklist, +find its corresponding ``OutstandingToken`` record in the admin and use the +admin again to create a ``BlacklistedToken`` record that points to the +``OutstandingToken`` record. + +Alternatively, you can blacklist a token by creating a ``BlacklistMixin`` +subclass instance and calling the instance's ``blacklist`` method: + +.. code-block:: python + + from rest_framework_simplejwt.tokens import RefreshToken + + token = RefreshToken(base64_encoded_token_string) + token.blacklist() + +This will create unique outstanding token and blacklist records for the token's +"jti" claim or whichever claim is specified by the ``JTI_CLAIM`` setting. + +The blacklist app also provides a management command, ``flushexpiredtokens``, +which will delete any tokens from the outstanding list and blacklist that have +expired. You should set up a cron job on your server or hosting platform which +runs this command daily. + +Experimental features +--------------------- + +JWTTokenUserAuthentication backend + The ``JWTTokenUserAuthentication`` backend's ``authenticate`` method does not + perform a database lookup to obtain a user instance. Instead, it returns a + ``rest_framework_simplejwt.models.TokenUser`` instance which acts as a + stateless user object backed only by a validated token instead of a record in + a database. This can facilitate developing single sign-on functionality + between separately hosted Django apps which all share the same token secret + key. To use this feature, add the + ``rest_framework_simplejwt.authentication.JWTTokenUserAuthentication`` + backend (instead of the default ``JWTAuthentication`` backend) to the Django + REST Framework's ``DEFAULT_AUTHENTICATION_CLASSES`` config setting: + + .. code-block:: python + + REST_FRAMEWORK = { + ... + 'DEFAULT_AUTHENTICATION_CLASSES': ( + ... + 'rest_framework_simplejwt.authentication.JWTTokenUserAuthentication', + ) + ... + } + +Development and Running the Tests +--------------------------------- + +To do development work for Simple JWT, make your own fork on Github, clone it +locally, make and activate a virtualenv for it, then from within the project +directory: + +.. code-block:: bash + + pip install --upgrade pip setuptools + pip install -e .[dev] + +To run the tests: + +.. code-block:: bash + + pytest + +To run the tests in all supported environments with tox, first `install pyenv +`__. Next, install the relevant +Python minor versions and create a ``.python-version`` file in the project +directory: + +.. code-block:: bash + + pyenv install 3.7.x + pyenv install 3.6.x + pyenv install 3.5.x + cat > .python-version <`__. diff --git a/rest_framework_simplejwt/authentication.py b/rest_framework_simplejwt/authentication.py index e0fa2f02e..ec1c43e9d 100644 --- a/rest_framework_simplejwt/authentication.py +++ b/rest_framework_simplejwt/authentication.py @@ -26,9 +26,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 7d91f7d4e..0243aa345 100644 --- a/rest_framework_simplejwt/settings.py +++ b/rest_framework_simplejwt/settings.py @@ -37,6 +37,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/urls.py b/tests/urls.py index 14e8a0b99..9633a600a 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -7,6 +7,7 @@ urlpatterns = [ re_path(r'^token/pair/$', jwt_views.token_obtain_pair, name='token_obtain_pair'), re_path(r'^token/refresh/$', jwt_views.token_refresh, name='token_refresh'), + re_path(r'^token/delete/$', jwt_views.token_delete, name='token_delete'), re_path(r'^token/sliding/$', jwt_views.token_obtain_sliding, name='token_obtain_sliding'), re_path(r'^token/sliding/refresh/$', jwt_views.token_refresh_sliding, name='token_refresh_sliding'), From 0222c8cac3ae55422714217035c14d15e0cb228b Mon Sep 17 00:00:00 2001 From: Niyaz Batyrshin Date: Wed, 2 Oct 2019 21:34:22 +0300 Subject: [PATCH 02/20] 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 5caea94c4..3d1c185d0 100644 --- a/README.rst +++ b/README.rst @@ -148,6 +148,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``: @@ -159,6 +161,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 ec1c43e9d..f5c0fc3f8 100644 --- a/rest_framework_simplejwt/authentication.py +++ b/rest_framework_simplejwt/authentication.py @@ -1,5 +1,6 @@ from django.utils.translation import gettext_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 .settings import api_settings @@ -16,6 +17,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 @@ -37,7 +51,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 429a5ad53117674753b024feb6a15783ff38d2c9 Mon Sep 17 00:00:00 2001 From: Niyaz Batyrshin Date: Wed, 2 Oct 2019 22:37:15 +0300 Subject: [PATCH 03/20] add curl example for X-CSRFToken usage --- README.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.rst b/README.rst index 3d1c185d0..50d07b309 100644 --- a/README.rst +++ b/README.rst @@ -166,6 +166,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 86e06a2b9976fcb3e1a159f706fc58e04cc73def Mon Sep 17 00:00:00 2001 From: Niyaz Batyrshin Date: Thu, 5 Mar 2020 00:07:54 +0300 Subject: [PATCH 04/20] 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 | 21 +++++++++++++++++++++ 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/README.rst b/README.rst index 50d07b309..840dbbe53 100644 --- a/README.rst +++ b/README.rst @@ -166,6 +166,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 \ @@ -175,7 +180,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 8b2a182a6..8d3f08625 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -71,6 +71,7 @@ def test_success(self): self.assertIn('access', res.data) self.assertIn('refresh', res.data) +<<<<<<< HEAD def test_update_last_login(self): self.view_post(data={ User.USERNAME_FIELD: self.username, @@ -93,6 +94,16 @@ def test_update_last_login(self): self.assertGreaterEqual(timezone.now(), user.last_login) reload(serializers) +======= + 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) +>>>>>>> Fixes bug: web (using cookie) and mobile (using request.data) did not work at the same time when AUTH_COOKIE is enabled class TestTokenRefreshView(APIViewTestCase): @@ -199,6 +210,7 @@ def test_success(self): self.assertEqual(res.status_code, 200) self.assertIn('token', res.data) +<<<<<<< HEAD def test_update_last_login(self): self.view_post(data={ User.USERNAME_FIELD: self.username, @@ -221,6 +233,15 @@ def test_update_last_login(self): self.assertGreaterEqual(timezone.now(), user.last_login) reload(serializers) +======= + 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) +>>>>>>> Fixes bug: web (using cookie) and mobile (using request.data) did not work at the same time when AUTH_COOKIE is enabled class TestTokenRefreshSlidingView(APIViewTestCase): From 7633349d3b6780163b1fda5e7755823790a05df6 Mon Sep 17 00:00:00 2001 From: Niyaz Batyrshin Date: Thu, 5 Mar 2020 00:41:39 +0300 Subject: [PATCH 05/20] `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), ) From ef1aa5eb21468ef786f0e85ae7ebe1bea4f5ba29 Mon Sep 17 00:00:00 2001 From: Wen-Ke Xing Date: Sat, 13 Jun 2020 10:12:59 +0800 Subject: [PATCH 06/20] use utc time for cookie expiry & samesite be configurable --- rest_framework_simplejwt/views.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rest_framework_simplejwt/views.py b/rest_framework_simplejwt/views.py index 351bd5977..6cb679640 100644 --- a/rest_framework_simplejwt/views.py +++ b/rest_framework_simplejwt/views.py @@ -91,12 +91,12 @@ def set_auth_cookies(self, response, data): path=reverse(self.token_refresh_view_name), secure=api_settings.AUTH_COOKIE_SECURE or None, httponly=True, - samesite='Strict', + samesite=api_settings.AUTH_COOKIE_SAMESITE, ) return response def get_refresh_token_expiration(self): - return datetime.now() + api_settings.REFRESH_TOKEN_LIFETIME + return datetime.utcnow() + api_settings.REFRESH_TOKEN_LIFETIME class TokenObtainPairView(TokenCookieViewMixin, TokenViewBase): @@ -141,7 +141,7 @@ def extract_token_from_cookie(self, request): def set_auth_cookies(self, response, data): response.set_cookie( api_settings.AUTH_COOKIE, data['token'], - expires=datetime.now() + api_settings.REFRESH_TOKEN_LIFETIME, + expires=datetime.utcnow() + 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, @@ -214,4 +214,4 @@ def delete_auth_cookies(self, response): ) -token_delete = TokenCookieDeleteView.as_view() +token_delete = TokenCookieDeleteView.as_view() \ No newline at end of file From 764af24af5cb75436c9637b2a0a26553968c924f Mon Sep 17 00:00:00 2001 From: loicgasser Date: Thu, 18 Jun 2020 09:41:53 -0400 Subject: [PATCH 07/20] Use utils method for datetime --- rest_framework_simplejwt/views.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/rest_framework_simplejwt/views.py b/rest_framework_simplejwt/views.py index 6cb679640..d5bcf325b 100644 --- a/rest_framework_simplejwt/views.py +++ b/rest_framework_simplejwt/views.py @@ -13,6 +13,7 @@ from . import serializers from .authentication import AUTH_HEADER_TYPES from .exceptions import InvalidToken, TokenError +from .utils import aware_utcnow, datetime_from_epoch class TokenViewBase(generics.GenericAPIView): @@ -96,7 +97,7 @@ def set_auth_cookies(self, response, data): return response def get_refresh_token_expiration(self): - return datetime.utcnow() + api_settings.REFRESH_TOKEN_LIFETIME + return aware_utcnow() + api_settings.REFRESH_TOKEN_LIFETIME class TokenObtainPairView(TokenCookieViewMixin, TokenViewBase): @@ -121,7 +122,7 @@ 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']) + return datetime_from_epoch(token.payload['exp']) token_refresh = TokenRefreshView.as_view() @@ -141,7 +142,7 @@ def extract_token_from_cookie(self, request): def set_auth_cookies(self, response, data): response.set_cookie( api_settings.AUTH_COOKIE, data['token'], - expires=datetime.utcnow() + api_settings.REFRESH_TOKEN_LIFETIME, + expires=aware_utcnow() + 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, From c25039d096d265d318c344c86b8446e51076bcf7 Mon Sep 17 00:00:00 2001 From: loicgasser Date: Thu, 18 Jun 2020 09:47:18 -0400 Subject: [PATCH 08/20] Use default CSRF django settings values --- rest_framework_simplejwt/settings.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rest_framework_simplejwt/settings.py b/rest_framework_simplejwt/settings.py index 0243aa345..5b021aa50 100644 --- a/rest_framework_simplejwt/settings.py +++ b/rest_framework_simplejwt/settings.py @@ -41,14 +41,14 @@ # 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, + 'AUTH_COOKIE_DOMAIN': settings.CSRF_COOKIE_DOMAIN, # Whether the auth cookies should be secure (https:// only). - 'AUTH_COOKIE_SECURE': False, + 'AUTH_COOKIE_SECURE': settings.CSRF_COOKIE_SECURE, # The path of the auth cookie. - 'AUTH_COOKIE_PATH': '/', + 'AUTH_COOKIE_PATH': settings.CSRF_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', + 'AUTH_COOKIE_SAMESITE': settings.CSRF_COOKIE_SAMESITE, } IMPORT_STRINGS = ( From 088623b1e64c1f44fa3f88f7cca7c3d9564ff99a Mon Sep 17 00:00:00 2001 From: loicgasser Date: Thu, 18 Jun 2020 09:49:31 -0400 Subject: [PATCH 09/20] Replace deprecated function --- rest_framework_simplejwt/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework_simplejwt/views.py b/rest_framework_simplejwt/views.py index d5bcf325b..32fb76146 100644 --- a/rest_framework_simplejwt/views.py +++ b/rest_framework_simplejwt/views.py @@ -1,7 +1,7 @@ from datetime import datetime from django.middleware import csrf -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from rest_framework import generics, status from rest_framework.exceptions import NotAuthenticated from rest_framework.response import Response From 8813c23b7cd11ee086e9633bc23c7af7a217e7de Mon Sep 17 00:00:00 2001 From: loicgasser Date: Thu, 18 Jun 2020 09:51:51 -0400 Subject: [PATCH 10/20] No need to return an empty dict --- rest_framework_simplejwt/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework_simplejwt/views.py b/rest_framework_simplejwt/views.py index 32fb76146..89fb9d5f4 100644 --- a/rest_framework_simplejwt/views.py +++ b/rest_framework_simplejwt/views.py @@ -195,7 +195,7 @@ class TokenCookieDeleteView(APIView): permission_classes = () def post(self, request): - response = Response({}) + response = Response() if api_settings.AUTH_COOKIE: self.delete_auth_cookies(response) From 2a25dc5520ffc4f0233ce93250d57d2f10b0dff2 Mon Sep 17 00:00:00 2001 From: loicgasser Date: Thu, 18 Jun 2020 10:31:40 -0400 Subject: [PATCH 11/20] Remove unused import --- rest_framework_simplejwt/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/rest_framework_simplejwt/views.py b/rest_framework_simplejwt/views.py index 89fb9d5f4..8645a3dca 100644 --- a/rest_framework_simplejwt/views.py +++ b/rest_framework_simplejwt/views.py @@ -1,5 +1,3 @@ -from datetime import datetime - from django.middleware import csrf from django.utils.translation import gettext_lazy as _ from rest_framework import generics, status From 0c245b41d77315eed98d324514b35ee4861ccc3c Mon Sep 17 00:00:00 2001 From: loicgasser Date: Thu, 18 Jun 2020 10:57:20 -0400 Subject: [PATCH 12/20] Fix AppRegistryNotReady --- rest_framework_simplejwt/authentication.py | 9 ++++++--- rest_framework_simplejwt/models.py | 5 ++--- rest_framework_simplejwt/serializers.py | 5 ++--- rest_framework_simplejwt/state.py | 4 +--- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/rest_framework_simplejwt/authentication.py b/rest_framework_simplejwt/authentication.py index f5c0fc3f8..bda816e81 100644 --- a/rest_framework_simplejwt/authentication.py +++ b/rest_framework_simplejwt/authentication.py @@ -1,10 +1,10 @@ +from django.contrib.auth import get_user_model from django.utils.translation import gettext_lazy as _ from rest_framework import HTTP_HEADER_ENCODING, authentication, exceptions from rest_framework.authentication import CSRFCheck from .exceptions import AuthenticationFailed, InvalidToken, TokenError from .settings import api_settings -from .state import User AUTH_HEADER_TYPES = api_settings.AUTH_HEADER_TYPES @@ -37,6 +37,9 @@ class JWTAuthentication(authentication.BaseAuthentication): """ www_authenticate_realm = 'api' + def __init__(self): + self.user_model = get_user_model() + def authenticate(self, request): header = self.get_header(request) if header is None: @@ -131,8 +134,8 @@ def get_user(self, validated_token): raise InvalidToken(_('Token contained no recognizable user identification')) try: - user = User.objects.get(**{api_settings.USER_ID_FIELD: user_id}) - except User.DoesNotExist: + user = self.user_model.objects.get(**{api_settings.USER_ID_FIELD: user_id}) + except self.user_model.DoesNotExist: raise AuthenticationFailed(_('User not found'), code='user_not_found') if not user.is_active: diff --git a/rest_framework_simplejwt/models.py b/rest_framework_simplejwt/models.py index d317788d5..c7b1c1859 100644 --- a/rest_framework_simplejwt/models.py +++ b/rest_framework_simplejwt/models.py @@ -19,11 +19,10 @@ class instead of a `User` model instance. Instances of this class act as # inactive user is_active = True - _groups = EmptyManager(auth_models.Group) - _user_permissions = EmptyManager(auth_models.Permission) - def __init__(self, token): self.token = token + self._groups = EmptyManager(auth_models.Group) + self._user_permissions = EmptyManager(auth_models.Permission) def __str__(self): return 'TokenUser {}'.format(self.id) diff --git a/rest_framework_simplejwt/serializers.py b/rest_framework_simplejwt/serializers.py index af8f4fc83..ae4b91cd5 100644 --- a/rest_framework_simplejwt/serializers.py +++ b/rest_framework_simplejwt/serializers.py @@ -1,12 +1,11 @@ import importlib -from django.contrib.auth import authenticate +from django.contrib.auth import authenticate, get_user_model from django.contrib.auth.models import update_last_login from django.utils.translation import gettext_lazy as _ from rest_framework import exceptions, serializers from .settings import api_settings -from .state import User from .tokens import RefreshToken, SlidingToken, UntypedToken rule_package, user_eligible_for_login = api_settings.USER_AUTHENTICATION_RULE.rsplit('.', 1) @@ -24,7 +23,7 @@ def __init__(self, *args, **kwargs): class TokenObtainSerializer(serializers.Serializer): - username_field = User.USERNAME_FIELD + username_field = get_user_model().USERNAME_FIELD default_error_messages = { 'no_active_account': _('No active account found with the given credentials') diff --git a/rest_framework_simplejwt/state.py b/rest_framework_simplejwt/state.py index 9a13e393a..88fca8d51 100644 --- a/rest_framework_simplejwt/state.py +++ b/rest_framework_simplejwt/state.py @@ -1,8 +1,6 @@ -from django.contrib.auth import get_user_model - from .backends import TokenBackend from .settings import api_settings -User = get_user_model() + token_backend = TokenBackend(api_settings.ALGORITHM, api_settings.SIGNING_KEY, api_settings.VERIFYING_KEY, api_settings.AUDIENCE, api_settings.ISSUER) From b510f2749248e0c332da3a0727e4aed5e3bca21b Mon Sep 17 00:00:00 2001 From: loicgasser Date: Thu, 18 Jun 2020 11:40:25 -0400 Subject: [PATCH 13/20] Fix the tests --- tests/test_integration.py | 16 +++++------ tests/test_serializers.py | 9 ++++--- tests/test_tokens.py | 4 +-- tests/test_views.py | 57 +++++++++++++-------------------------- 4 files changed, 33 insertions(+), 53 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 2f4b260c2..42fa46fe3 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,11 +1,11 @@ from datetime import timedelta from django.conf import settings +from django.contrib.auth import get_user_model 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 from rest_framework_simplejwt.tokens import AccessToken from .utils import APIViewTestCase, override_api_settings @@ -17,7 +17,7 @@ def setUp(self): self.username = 'test_user' self.password = 'test_password' - self.user = User.objects.create_user( + self.user = get_user_model().objects.create_user( username=self.username, password=self.password, ) @@ -32,7 +32,7 @@ def test_wrong_auth_type(self): res = self.client.post( reverse('token_obtain_sliding'), data={ - User.USERNAME_FIELD: self.username, + get_user_model().USERNAME_FIELD: self.username, 'password': self.password, }, ) @@ -52,7 +52,7 @@ def test_expired_token(self): res = self.client.post( reverse('token_obtain_pair'), data={ - User.USERNAME_FIELD: self.username, + get_user_model().USERNAME_FIELD: self.username, 'password': self.password, }, ) @@ -72,7 +72,7 @@ def test_user_can_get_sliding_token_and_use_it(self): res = self.client.post( reverse('token_obtain_sliding'), data={ - User.USERNAME_FIELD: self.username, + get_user_model().USERNAME_FIELD: self.username, 'password': self.password, }, ) @@ -99,7 +99,7 @@ def test_user_can_get_access_refresh_and_delete_sliding_token_cookies_and_use_th res = client.post( reverse('token_obtain_sliding'), data={ - User.USERNAME_FIELD: self.username, + get_user_model().USERNAME_FIELD: self.username, 'password': self.password, }, ) @@ -147,7 +147,7 @@ def test_user_can_get_access_and_refresh_tokens_and_use_them(self): res = self.client.post( reverse('token_obtain_pair'), data={ - User.USERNAME_FIELD: self.username, + get_user_model().USERNAME_FIELD: self.username, 'password': self.password, }, ) @@ -189,7 +189,7 @@ def test_user_can_get_access_refresh_and_delete_token_cookies_and_use_them(self) res = client.post( reverse('token_obtain_pair'), data={ - User.USERNAME_FIELD: self.username, + get_user_model().USERNAME_FIELD: self.username, 'password': self.password, }, ) diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 7cc2326d3..4550fd22a 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock, patch from django.test import TestCase +from django.contrib.auth import get_user_model + from rest_framework import exceptions as drf_exceptions from rest_framework_simplejwt.exceptions import TokenError @@ -11,7 +13,6 @@ TokenRefreshSlidingSerializer, TokenVerifySerializer, ) from rest_framework_simplejwt.settings import api_settings -from rest_framework_simplejwt.state import User from rest_framework_simplejwt.token_blacklist.models import ( BlacklistedToken, OutstandingToken, ) @@ -30,7 +31,7 @@ def setUp(self): self.username = 'test_user' self.password = 'test_password' - self.user = User.objects.create_user( + self.user = get_user_model().objects.create_user( username=self.username, password=self.password, ) @@ -80,7 +81,7 @@ def setUp(self): self.username = 'test_user' self.password = 'test_password' - self.user = User.objects.create_user( + self.user = get_user_model().objects.create_user( username=self.username, password=self.password, ) @@ -105,7 +106,7 @@ def setUp(self): self.username = 'test_user' self.password = 'test_password' - self.user = User.objects.create_user( + self.user = get_user_model().objects.create_user( username=self.username, password=self.password, ) diff --git a/tests/test_tokens.py b/tests/test_tokens.py index 8d587224d..4a46ecf96 100644 --- a/tests/test_tokens.py +++ b/tests/test_tokens.py @@ -2,11 +2,11 @@ from unittest.mock import patch from django.test import TestCase +from django.contrib.auth import get_user_model from jose import jwt from rest_framework_simplejwt.exceptions import TokenError from rest_framework_simplejwt.settings import api_settings -from rest_framework_simplejwt.state import User from rest_framework_simplejwt.tokens import ( AccessToken, RefreshToken, SlidingToken, Token, UntypedToken, ) @@ -280,7 +280,7 @@ def test_check_exp(self): def test_for_user(self): username = 'test_user' - user = User.objects.create_user( + user = get_user_model().objects.create_user( username=username, password='test_password', ) diff --git a/tests/test_views.py b/tests/test_views.py index 8d3f08625..fba7de9d2 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -2,11 +2,11 @@ from importlib import reload from unittest.mock import patch +from django.contrib.auth import get_user_model from django.utils import timezone from rest_framework_simplejwt import serializers from rest_framework_simplejwt.settings import api_settings -from rest_framework_simplejwt.state import User from rest_framework_simplejwt.tokens import ( AccessToken, RefreshToken, SlidingToken, ) @@ -24,7 +24,7 @@ def setUp(self): self.username = 'test_user' self.password = 'test_password' - self.user = User.objects.create_user( + self.user = get_user_model().objects.create_user( username=self.username, password=self.password, ) @@ -32,20 +32,20 @@ def setUp(self): def test_fields_missing(self): res = self.view_post(data={}) self.assertEqual(res.status_code, 400) - self.assertIn(User.USERNAME_FIELD, res.data) + self.assertIn(get_user_model().USERNAME_FIELD, res.data) self.assertIn('password', res.data) - res = self.view_post(data={User.USERNAME_FIELD: self.username}) + res = self.view_post(data={get_user_model().USERNAME_FIELD: self.username}) self.assertEqual(res.status_code, 400) self.assertIn('password', res.data) res = self.view_post(data={'password': self.password}) self.assertEqual(res.status_code, 400) - self.assertIn(User.USERNAME_FIELD, res.data) + self.assertIn(get_user_model().USERNAME_FIELD, res.data) def test_credentials_wrong(self): res = self.view_post(data={ - User.USERNAME_FIELD: self.username, + get_user_model().USERNAME_FIELD: self.username, 'password': 'test_user', }) self.assertEqual(res.status_code, 401) @@ -56,7 +56,7 @@ def test_user_inactive(self): self.user.save() res = self.view_post(data={ - User.USERNAME_FIELD: self.username, + get_user_model().USERNAME_FIELD: self.username, 'password': self.password, }) self.assertEqual(res.status_code, 401) @@ -64,14 +64,13 @@ def test_user_inactive(self): def test_success(self): res = self.view_post(data={ - User.USERNAME_FIELD: self.username, + get_user_model().USERNAME_FIELD: self.username, 'password': self.password, }) self.assertEqual(res.status_code, 200) self.assertIn('access', res.data) self.assertIn('refresh', res.data) -<<<<<<< HEAD def test_update_last_login(self): self.view_post(data={ User.USERNAME_FIELD: self.username, @@ -94,16 +93,6 @@ def test_update_last_login(self): self.assertGreaterEqual(timezone.now(), user.last_login) reload(serializers) -======= - 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) ->>>>>>> Fixes bug: web (using cookie) and mobile (using request.data) did not work at the same time when AUTH_COOKIE is enabled class TestTokenRefreshView(APIViewTestCase): @@ -113,7 +102,7 @@ def setUp(self): self.username = 'test_user' self.password = 'test_password' - self.user = User.objects.create_user( + self.user = get_user_model().objects.create_user( username=self.username, password=self.password, ) @@ -164,7 +153,7 @@ def setUp(self): self.username = 'test_user' self.password = 'test_password' - self.user = User.objects.create_user( + self.user = get_user_model().objects.create_user( username=self.username, password=self.password, ) @@ -172,20 +161,20 @@ def setUp(self): def test_fields_missing(self): res = self.view_post(data={}) self.assertEqual(res.status_code, 400) - self.assertIn(User.USERNAME_FIELD, res.data) + self.assertIn(get_user_model().USERNAME_FIELD, res.data) self.assertIn('password', res.data) - res = self.view_post(data={User.USERNAME_FIELD: self.username}) + res = self.view_post(data={get_user_model().USERNAME_FIELD: self.username}) self.assertEqual(res.status_code, 400) self.assertIn('password', res.data) res = self.view_post(data={'password': self.password}) self.assertEqual(res.status_code, 400) - self.assertIn(User.USERNAME_FIELD, res.data) + self.assertIn(get_user_model().USERNAME_FIELD, res.data) def test_credentials_wrong(self): res = self.view_post(data={ - User.USERNAME_FIELD: self.username, + get_user_model().USERNAME_FIELD: self.username, 'password': 'test_user', }) self.assertEqual(res.status_code, 401) @@ -196,7 +185,7 @@ def test_user_inactive(self): self.user.save() res = self.view_post(data={ - User.USERNAME_FIELD: self.username, + get_user_model().USERNAME_FIELD: self.username, 'password': self.password, }) self.assertEqual(res.status_code, 401) @@ -204,13 +193,12 @@ def test_user_inactive(self): def test_success(self): res = self.view_post(data={ - User.USERNAME_FIELD: self.username, + get_user_model().USERNAME_FIELD: self.username, 'password': self.password, }) self.assertEqual(res.status_code, 200) self.assertIn('token', res.data) -<<<<<<< HEAD def test_update_last_login(self): self.view_post(data={ User.USERNAME_FIELD: self.username, @@ -233,15 +221,6 @@ def test_update_last_login(self): self.assertGreaterEqual(timezone.now(), user.last_login) reload(serializers) -======= - 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) ->>>>>>> Fixes bug: web (using cookie) and mobile (using request.data) did not work at the same time when AUTH_COOKIE is enabled class TestTokenRefreshSlidingView(APIViewTestCase): @@ -251,7 +230,7 @@ def setUp(self): self.username = 'test_user' self.password = 'test_password' - self.user = User.objects.create_user( + self.user = get_user_model().objects.create_user( username=self.username, password=self.password, ) @@ -316,7 +295,7 @@ def setUp(self): self.username = 'test_user' self.password = 'test_password' - self.user = User.objects.create_user( + self.user = get_user_model().objects.create_user( username=self.username, password=self.password, ) From 152ed94ccbbb47b43e7c8f53d8eafda4dbac042d Mon Sep 17 00:00:00 2001 From: loicgasser Date: Thu, 18 Jun 2020 13:16:31 -0400 Subject: [PATCH 14/20] Remove duplicated code by creating BaseTokenCookieViewMixin --- rest_framework_simplejwt/views.py | 70 ++++++++++++++----------------- 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/rest_framework_simplejwt/views.py b/rest_framework_simplejwt/views.py index 8645a3dca..9c4d52dfe 100644 --- a/rest_framework_simplejwt/views.py +++ b/rest_framework_simplejwt/views.py @@ -58,45 +58,50 @@ def post(self, request, *args, **kwargs): return super().post(request, *args, **kwargs) -class TokenCookieViewMixin: - token_refresh_view_name = 'token_refresh' +class BaseTokenCookieViewMixin: 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: - token = request.COOKIES.get('{}_refresh'.format(api_settings.AUTH_COOKIE)) + token = request.COOKIES.get(self.token_refresh_cookie_name) if not token: raise NotAuthenticated(detail=_('Refresh cookie not set. Try to authenticate first.')) - else: - request.data['refresh'] = token + request.data[self.token_refresh_cookie_name] = token return request + def get_cookie_data(self): + return { + 'expires': self.get_refresh_token_expiration(), + '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 + } + + @staticmethod + def get_refresh_token_expiration(): + return aware_utcnow() + api_settings.REFRESH_TOKEN_LIFETIME + + +class TokenCookieViewMixin(BaseTokenCookieViewMixin): + token_refresh_view_name = 'token_refresh' + token_refresh_cookie_name = '{}_refresh'.format(api_settings.AUTH_COOKIE) + token_refresh_data_key = 'refresh' + def set_auth_cookies(self, response, data): - expires = self.get_refresh_token_expiration() + cookie_data = self.get_cookie_data() 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, + **cookie_data ) if 'refresh' in data: response.set_cookie( '{}_refresh'.format(api_settings.AUTH_COOKIE), data['refresh'], - expires=expires, - domain=None, - path=reverse(self.token_refresh_view_name), - secure=api_settings.AUTH_COOKIE_SECURE or None, - httponly=True, - samesite=api_settings.AUTH_COOKIE_SAMESITE, + **{**cookie_data, **{'domain': None, 'path': reverse(self.token_refresh_view_name)}} ) return response - def get_refresh_token_expiration(self): - return aware_utcnow() + api_settings.REFRESH_TOKEN_LIFETIME - class TokenObtainPairView(TokenCookieViewMixin, TokenViewBase): """ @@ -126,26 +131,15 @@ def get_refresh_token_expiration(self): token_refresh = TokenRefreshView.as_view() -class SlidingTokenCookieViewMixin: - 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: - 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 +class SlidingTokenCookieViewMixin(BaseTokenCookieViewMixin): + token_refresh_cookie_name = api_settings.AUTH_COOKIE + token_refresh_data_key = 'token' def set_auth_cookies(self, response, data): + cookie_data = self.get_cookie_data() response.set_cookie( api_settings.AUTH_COOKIE, data['token'], - expires=aware_utcnow() + 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, + **cookie_data ) return response @@ -213,4 +207,4 @@ def delete_auth_cookies(self, response): ) -token_delete = TokenCookieDeleteView.as_view() \ No newline at end of file +token_delete = TokenCookieDeleteView.as_view() From 3d8a38adacf678871661589302e64c1512b02f51 Mon Sep 17 00:00:00 2001 From: loicgasser Date: Fri, 19 Jun 2020 08:02:44 -0400 Subject: [PATCH 15/20] Fix typo --- rest_framework_simplejwt/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework_simplejwt/views.py b/rest_framework_simplejwt/views.py index 9c4d52dfe..ee4c202ba 100644 --- a/rest_framework_simplejwt/views.py +++ b/rest_framework_simplejwt/views.py @@ -66,7 +66,7 @@ def extract_token_from_cookie(self, request): token = request.COOKIES.get(self.token_refresh_cookie_name) if not token: raise NotAuthenticated(detail=_('Refresh cookie not set. Try to authenticate first.')) - request.data[self.token_refresh_cookie_name] = token + request.data[self.token_refresh_data_key] = token return request def get_cookie_data(self): From ef7f22abfb155b2502148b05a3efb98c7d8336a8 Mon Sep 17 00:00:00 2001 From: loicgasser Date: Sun, 12 Jul 2020 21:00:23 -0400 Subject: [PATCH 16/20] Return expiry, fix path for cookie --- rest_framework_simplejwt/serializers.py | 11 +++- rest_framework_simplejwt/views.py | 69 ++++++++++++++++--------- 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/rest_framework_simplejwt/serializers.py b/rest_framework_simplejwt/serializers.py index ae4b91cd5..2fc077110 100644 --- a/rest_framework_simplejwt/serializers.py +++ b/rest_framework_simplejwt/serializers.py @@ -69,9 +69,14 @@ def validate(self, attrs): data = super().validate(attrs) refresh = self.get_token(self.user) + access = refresh.access_token + data['access'] = str(access) data['refresh'] = str(refresh) - data['access'] = str(refresh.access_token) + + # Add expiry time as unix timestamp + data['access_expiry'] = access.payload['exp'] * 1000 + data['refresh_expiry'] = refresh.payload['exp'] * 1000 if api_settings.UPDATE_LAST_LOGIN: update_last_login(None, self.user) @@ -103,7 +108,8 @@ class TokenRefreshSerializer(serializers.Serializer): def validate(self, attrs): refresh = RefreshToken(attrs['refresh']) - data = {'access': str(refresh.access_token)} + access = refresh.access_token + data = {'access': str(access), 'access_expiry': access.payload['exp'] * 1000} if api_settings.ROTATE_REFRESH_TOKENS: if api_settings.BLACKLIST_AFTER_ROTATION: @@ -119,6 +125,7 @@ def validate(self, attrs): refresh.set_exp() data['refresh'] = str(refresh) + data['refresh_expiry'] = refresh.payload['exp'] * 1000 return data diff --git a/rest_framework_simplejwt/views.py b/rest_framework_simplejwt/views.py index ee4c202ba..5eea56631 100644 --- a/rest_framework_simplejwt/views.py +++ b/rest_framework_simplejwt/views.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.middleware import csrf from django.utils.translation import gettext_lazy as _ from rest_framework import generics, status @@ -36,15 +37,32 @@ def post(self, request, *args, **kwargs): except TokenError as e: raise InvalidToken(e.args[0]) - response = Response(serializer.validated_data, status=status.HTTP_200_OK) + data = serializer.validated_data if api_settings.AUTH_COOKIE: - csrf.get_token(self.request) - response = self.set_auth_cookies(response, serializer.validated_data) + csrf_token = csrf.get_token(self.request) + cookie_data = self.get_cookie_data() + data['csrf_token'] = csrf_token + + response = Response(data, status=status.HTTP_200_OK) + + if api_settings.AUTH_COOKIE: + response = self.set_auth_cookies( + response, serializer.validated_data, cookie_data) return response - def set_auth_cookies(self, response, data): + def get_cookie_data(self): + return { + 'expires': self.get_refresh_token_expiration(), + '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 + } + + def set_auth_cookies(self, response, data, cookie_data): return response @@ -69,16 +87,6 @@ def extract_token_from_cookie(self, request): request.data[self.token_refresh_data_key] = token return request - def get_cookie_data(self): - return { - 'expires': self.get_refresh_token_expiration(), - '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 - } - @staticmethod def get_refresh_token_expiration(): return aware_utcnow() + api_settings.REFRESH_TOKEN_LIFETIME @@ -89,16 +97,23 @@ class TokenCookieViewMixin(BaseTokenCookieViewMixin): token_refresh_cookie_name = '{}_refresh'.format(api_settings.AUTH_COOKIE) token_refresh_data_key = 'refresh' - def set_auth_cookies(self, response, data): - cookie_data = self.get_cookie_data() + def set_auth_cookies(self, response, data, cookie_data): response.set_cookie( - api_settings.AUTH_COOKIE, data['access'], + api_settings.AUTH_COOKIE, + data['access'], **cookie_data ) if 'refresh' in data: response.set_cookie( - '{}_refresh'.format(api_settings.AUTH_COOKIE), data['refresh'], - **{**cookie_data, **{'domain': None, 'path': reverse(self.token_refresh_view_name)}} + '{}_refresh'.format(api_settings.AUTH_COOKIE), + data['refresh'], + **{ + **cookie_data, + **{ + 'domain': api_settings.AUTH_COOKIE_DOMAIN, + 'path': reverse(self.token_refresh_view_name) + } + } ) return response @@ -135,10 +150,10 @@ class SlidingTokenCookieViewMixin(BaseTokenCookieViewMixin): token_refresh_cookie_name = api_settings.AUTH_COOKIE token_refresh_data_key = 'token' - def set_auth_cookies(self, response, data): - cookie_data = self.get_cookie_data() + def set_auth_cookies(self, response, data, cookie_data): response.set_cookie( - api_settings.AUTH_COOKIE, data['token'], + api_settings.AUTH_COOKIE, + data['token'], **cookie_data ) return response @@ -191,6 +206,7 @@ def post(self, request): if api_settings.AUTH_COOKIE: self.delete_auth_cookies(response) + self.delete_csrf_cookie(response) return response @@ -202,9 +218,16 @@ def delete_auth_cookies(self, response): ) response.delete_cookie( '{}_refresh'.format(api_settings.AUTH_COOKIE), - domain=None, + domain=api_settings.AUTH_COOKIE_DOMAIN, path=reverse(self.token_refresh_view_name), ) + def delete_csrf_cookie(self, response): + response.delete_cookie( + settings.CSRF_COOKIE_NAME, + domain=api_settings.AUTH_COOKIE_DOMAIN, + path=api_settings.AUTH_COOKIE_PATH + ) + token_delete = TokenCookieDeleteView.as_view() From f9b0980b8705848ee6196393a0c057456e3b912c Mon Sep 17 00:00:00 2001 From: loicgasser Date: Thu, 23 Jul 2020 19:13:45 -0400 Subject: [PATCH 17/20] Do not return token in body fix the messy messy tests, will add some more --- Makefile | 4 + docs/conf.py | 2 +- rest_framework_simplejwt/authentication.py | 4 +- rest_framework_simplejwt/serializers.py | 7 +- rest_framework_simplejwt/state.py | 1 - rest_framework_simplejwt/views.py | 30 ++- setup.py | 5 +- tests/test_integration.py | 249 ++++++++++++--------- tests/test_serializers.py | 3 +- tests/test_tokens.py | 2 +- tests/test_views.py | 61 +++-- tests/utils.py | 1 + 12 files changed, 209 insertions(+), 160 deletions(-) diff --git a/Makefile b/Makefile index e391efab7..6864d368d 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ CURRENT_SIGN_SETTING := $(shell git config commit.gpgSign) help: @echo "clean-build - remove build artifacts" @echo "clean-pyc - remove Python file artifacts" + @echo "isortfix - fixes the imports order" @echo "lint - check style with flake8" @echo "test - run tests quickly with the default Python" @echo "testall - run tests on every Python version with tox" @@ -23,6 +24,9 @@ clean-pyc: find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + +isortfix: + isort --recursive --skip migrations docs + lint: tox -e lint diff --git a/docs/conf.py b/docs/conf.py index b3d97f220..9e611fdd0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,6 +17,7 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. #sys.path.insert(0, os.path.abspath('.')) +import doctest import os DIR = os.path.dirname('__file__') @@ -306,7 +307,6 @@ def django_configure(): # -- Doctest configuration ---------------------------------------- -import doctest doctest_default_flags = (0 | doctest.DONT_ACCEPT_TRUE_FOR_1 diff --git a/rest_framework_simplejwt/authentication.py b/rest_framework_simplejwt/authentication.py index bda816e81..14dedc2f4 100644 --- a/rest_framework_simplejwt/authentication.py +++ b/rest_framework_simplejwt/authentication.py @@ -45,10 +45,10 @@ def authenticate(self, request): if header is None: if not api_settings.AUTH_COOKIE: return None - else: - raw_token = request.COOKIES.get(api_settings.AUTH_COOKIE) or None + 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/serializers.py b/rest_framework_simplejwt/serializers.py index 2fc077110..29d71dc9b 100644 --- a/rest_framework_simplejwt/serializers.py +++ b/rest_framework_simplejwt/serializers.py @@ -74,10 +74,6 @@ def validate(self, attrs): data['access'] = str(access) data['refresh'] = str(refresh) - # Add expiry time as unix timestamp - data['access_expiry'] = access.payload['exp'] * 1000 - data['refresh_expiry'] = refresh.payload['exp'] * 1000 - if api_settings.UPDATE_LAST_LOGIN: update_last_login(None, self.user) @@ -109,7 +105,7 @@ def validate(self, attrs): refresh = RefreshToken(attrs['refresh']) access = refresh.access_token - data = {'access': str(access), 'access_expiry': access.payload['exp'] * 1000} + data = {'access': str(access)} if api_settings.ROTATE_REFRESH_TOKENS: if api_settings.BLACKLIST_AFTER_ROTATION: @@ -125,7 +121,6 @@ def validate(self, attrs): refresh.set_exp() data['refresh'] = str(refresh) - data['refresh_expiry'] = refresh.payload['exp'] * 1000 return data diff --git a/rest_framework_simplejwt/state.py b/rest_framework_simplejwt/state.py index 88fca8d51..697a79e3d 100644 --- a/rest_framework_simplejwt/state.py +++ b/rest_framework_simplejwt/state.py @@ -1,6 +1,5 @@ from .backends import TokenBackend from .settings import api_settings - token_backend = TokenBackend(api_settings.ALGORITHM, api_settings.SIGNING_KEY, api_settings.VERIFYING_KEY, api_settings.AUDIENCE, api_settings.ISSUER) diff --git a/rest_framework_simplejwt/views.py b/rest_framework_simplejwt/views.py index 5eea56631..59e508444 100644 --- a/rest_framework_simplejwt/views.py +++ b/rest_framework_simplejwt/views.py @@ -9,6 +9,7 @@ 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 @@ -39,18 +40,15 @@ def post(self, request, *args, **kwargs): data = serializer.validated_data + # Don't return the token in the response body if the auth tokens are in a httpOnly cookie + # Only return the CSRF token if api_settings.AUTH_COOKIE: csrf_token = csrf.get_token(self.request) cookie_data = self.get_cookie_data() - data['csrf_token'] = csrf_token - - response = Response(data, status=status.HTTP_200_OK) - - if api_settings.AUTH_COOKIE: - response = self.set_auth_cookies( - response, serializer.validated_data, cookie_data) + response = Response({'csrf_token': csrf_token}, status=status.HTTP_200_OK) + return self.set_auth_cookies(response, data, cookie_data) - return response + return Response(data, status=status.HTTP_200_OK) def get_cookie_data(self): return { @@ -65,6 +63,9 @@ def get_cookie_data(self): def set_auth_cookies(self, response, data, cookie_data): return response + def get_refresh_token_expiration(self): + return aware_utcnow() + api_settings.REFRESH_TOKEN_LIFETIME + class TokenRefreshViewBase(TokenViewBase): def extract_token_from_cookie(self, request): @@ -87,16 +88,18 @@ def extract_token_from_cookie(self, request): request.data[self.token_refresh_data_key] = token return request - @staticmethod - def get_refresh_token_expiration(): + def get_refresh_token_expiration(self): return aware_utcnow() + api_settings.REFRESH_TOKEN_LIFETIME class TokenCookieViewMixin(BaseTokenCookieViewMixin): token_refresh_view_name = 'token_refresh' - token_refresh_cookie_name = '{}_refresh'.format(api_settings.AUTH_COOKIE) token_refresh_data_key = 'refresh' + @property + def token_refresh_cookie_name(self): + return '{}_refresh'.format(api_settings.AUTH_COOKIE) + def set_auth_cookies(self, response, data, cookie_data): response.set_cookie( api_settings.AUTH_COOKIE, @@ -147,9 +150,12 @@ def get_refresh_token_expiration(self): class SlidingTokenCookieViewMixin(BaseTokenCookieViewMixin): - token_refresh_cookie_name = api_settings.AUTH_COOKIE token_refresh_data_key = 'token' + @property + def token_refresh_cookie_name(self): + return api_settings.AUTH_COOKIE + def set_auth_cookies(self, response, data, cookie_data): response.set_cookie( api_settings.AUTH_COOKIE, diff --git a/setup.py b/setup.py index f5f8b5edd..a18e67199 100755 --- a/setup.py +++ b/setup.py @@ -1,8 +1,5 @@ #!/usr/bin/env python -from setuptools import ( - setup, - find_packages, -) +from setuptools import find_packages, setup extras_require = { 'test': [ diff --git a/tests/test_integration.py b/tests/test_integration.py index 42fa46fe3..7f00c425d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,11 +2,11 @@ from django.conf import settings from django.contrib.auth import get_user_model -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.tokens import AccessToken + from .utils import APIViewTestCase, override_api_settings @@ -16,8 +16,8 @@ class TestTestView(APIViewTestCase): def setUp(self): self.username = 'test_user' self.password = 'test_password' - - self.user = get_user_model().objects.create_user( + self.user_model = get_user_model() + self.user = self.user_model.objects.create_user( username=self.username, password=self.password, ) @@ -32,7 +32,7 @@ def test_wrong_auth_type(self): res = self.client.post( reverse('token_obtain_sliding'), data={ - get_user_model().USERNAME_FIELD: self.username, + self.user_model.USERNAME_FIELD: self.username, 'password': self.password, }, ) @@ -52,7 +52,7 @@ def test_expired_token(self): res = self.client.post( reverse('token_obtain_pair'), data={ - get_user_model().USERNAME_FIELD: self.username, + self.user_model.USERNAME_FIELD: self.username, 'password': self.password, }, ) @@ -72,7 +72,7 @@ def test_user_can_get_sliding_token_and_use_it(self): res = self.client.post( reverse('token_obtain_sliding'), data={ - get_user_model().USERNAME_FIELD: self.username, + self.user_model.USERNAME_FIELD: self.username, 'password': self.password, }, ) @@ -86,68 +86,11 @@ 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', - AUTH_TOKEN_CLASSES=('rest_framework_simplejwt.tokens.SlidingToken',)): - client = self.client_class(enforce_csrf_checks=True) - res = client.post( - reverse('token_obtain_sliding'), - data={ - get_user_model().USERNAME_FIELD: self.username, - 'password': self.password, - }, - ) - - 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 = 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 = client.get(reverse(self.view_name)) - - self.assertEqual(res.status_code, 200) - self.assertEqual(res.data['foo'], 'bar') - - res = client.post( - reverse('token_delete'), - ) - - res = client.get(reverse(self.view_name)) - self.assertEqual(res.status_code, 401) - - res = 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'), data={ - get_user_model().USERNAME_FIELD: self.username, + self.user_model.USERNAME_FIELD: self.username, 'password': self.password, }, ) @@ -178,57 +121,163 @@ 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', ): - client = self.client_class(enforce_csrf_checks=True) - res = client.post( - reverse('token_obtain_pair'), +class TestTestViewWithCookie(APIViewTestCase): + + view_name = 'test_view' + + def setUp(self): + self.username = 'test_user' + self.password = 'test_password' + self.user_model = get_user_model() + self.user = self.user_model.objects.create_user( + username=self.username, + password=self.password, + ) + self.client = self.client_class(enforce_csrf_checks=True) + + def test_no_authorization_with_auth_cookie(self): + auth_cookie_name = 'authorization' + with override_api_settings(AUTH_COOKIE=auth_cookie_name, AUTH_TOKEN_CLASSES=('rest_framework_simplejwt.tokens.AccessToken',)): + res = self.view_get() + + self.assertEqual(res.status_code, 401) + self.assertIn('credentials were not provided', res.data['detail']) + + def test_user_can_get_access_refresh_and_delete_sliding_token_and_use_them_with_auth_cookie(self): + auth_cookie_name = 'authorization' + auth_refresh_cookie_name = '%s_refresh' % auth_cookie_name + with override_api_settings(AUTH_COOKIE=auth_cookie_name, AUTH_TOKEN_CLASSES=('rest_framework_simplejwt.tokens.SlidingToken',)): + res = self.client.post( + reverse('token_obtain_sliding'), data={ - get_user_model().USERNAME_FIELD: self.username, + self.user_model.USERNAME_FIELD: self.username, 'password': self.password, }, ) + self.assertNotIn('access', res.data) + self.assertGreater(len(res.cookies.get(auth_cookie_name).value), 0) + # Sliding tokens don't have a refresh token, it's a splippery slope if you ask me + self.assertIsNone(res.cookies.get(auth_refresh_cookie_name)) + self.assertEqual(res.status_code, 200) + csrf_token = res.data['csrf_token'] - csrf_cookie = res.wsgi_request.environ['CSRF_COOKIE'] - client.cookies.load({settings.CSRF_COOKIE_NAME: csrf_cookie}) - - res = client.get(reverse(self.view_name)) + with override_api_settings(AUTH_COOKIE=auth_cookie_name, AUTH_TOKEN_CLASSES=('rest_framework_simplejwt.tokens.SlidingToken',)): + # Get on test view, this should work + res = self.view_get() + self.assertEqual(res.status_code, 200) + self.assertEqual(res.data['foo'], 'bar') - self.assertEqual(res.status_code, 200) - self.assertEqual(res.data['foo'], 'bar') + # Refresh the token + with override_api_settings(AUTH_COOKIE=auth_cookie_name): + res = self.client.post(reverse('token_refresh_sliding')) + self.assertEqual(res.status_code, 200) + self.assertGreater(len(res.cookies.get(auth_cookie_name).value), 0) + self.assertIsNone(res.cookies.get(auth_refresh_cookie_name)) - res = client.post(reverse(self.view_name)) + # Get again + with override_api_settings(AUTH_COOKIE=auth_cookie_name, AUTH_TOKEN_CLASSES=('rest_framework_simplejwt.tokens.SlidingToken',)): + res = self.view_get() + self.assertEqual(res.status_code, 200) + self.assertEqual(res.data['foo'], 'bar') - self.assertEqual(res.status_code, 403) - self.assertTrue(REASON_BAD_TOKEN in res.data['detail']) + # Try a post without CSRF + with override_api_settings(AUTH_COOKIE=auth_cookie_name, AUTH_TOKEN_CLASSES=('rest_framework_simplejwt.tokens.SlidingToken',)): + res = self.view_post(data={}) + self.assertEqual(res.status_code, 403) - res = client.post(reverse(self.view_name), **{settings.CSRF_HEADER_NAME: csrf_cookie}) + # Add CSRF + self.client.credentials(HTTP_X_CSRFTOKEN=csrf_token) + self.client.cookies[settings.CSRF_COOKIE_NAME] = csrf_token + with override_api_settings(AUTH_COOKIE=auth_cookie_name, AUTH_TOKEN_CLASSES=('rest_framework_simplejwt.tokens.SlidingToken',)): + res = self.view_post(data={}) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.data['foo'], 'bar') + self.assertEqual(res.status_code, 200) - res = client.post( - reverse('token_refresh'), + # Delete cookies + with override_api_settings(AUTH_COOKIE=auth_cookie_name): + res = self.client.post(reverse('token_delete')) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.cookies.get(auth_cookie_name).value, '') + self.assertEqual(res.cookies.get(auth_refresh_cookie_name).value, '') + self.assertEqual(res.cookies.get(settings.CSRF_COOKIE_NAME).value, '') + + def test_user_can_get_access_refresh_and_delete_tokens_and_use_them_with_auth_cookie(self): + auth_cookie_name = 'authorization' + auth_refresh_cookie_name = '%s_refresh' % auth_cookie_name + with override_api_settings(AUTH_COOKIE=auth_cookie_name, AUTH_TOKEN_CLASSES=('rest_framework_simplejwt.tokens.SlidingToken',)): + res = self.client.post( + reverse('token_obtain_pair'), + data={ + self.user_model.USERNAME_FIELD: self.username, + 'password': self.password, + }, ) - res = client.get(reverse(self.view_name)) + # There is no reason to have the tokens in the response body we set them in the cookie + self.assertNotIn('access', res.data) + self.assertNotIn('refresh', res.data) + self.assertIsNotNone(res.data['csrf_token']) + # Make sure set cookie is called + self.assertGreater(len(res.cookies.get(auth_cookie_name).value), 0) + self.assertGreater(len(res.cookies.get(auth_refresh_cookie_name).value), 0) + # Get the csrf token + csrf_token = res.data['csrf_token'] + + # Get on test view + with override_api_settings(AUTH_COOKIE=auth_cookie_name, AUTH_TOKEN_CLASSES=('rest_framework_simplejwt.tokens.AccessToken',)): + res = self.view_get() - self.assertEqual(res.status_code, 200) - self.assertEqual(res.data['foo'], 'bar') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.data['foo'], 'bar') - res = client.post( - reverse('token_delete'), - ) + # Refresh the token + with override_api_settings(AUTH_COOKIE=auth_cookie_name, ROTATE_REFRESH_TOKENS=False): + res = self.client.post(reverse('token_refresh')) + self.assertEqual(res.status_code, 200) + # Make sure we only update the access token + self.assertGreater(len(res.cookies.get(auth_cookie_name).value), 0) + self.assertIsNone(res.cookies.get(auth_refresh_cookie_name)) + self.assertNotIn('access', res.data) + self.assertNotIn('refresh', res.data) + self.assertIn('csrf_token', res.data) - res = client.get(reverse(self.view_name)) - self.assertEqual(res.status_code, 401) + # Now refresh token with rotation enabled + with override_api_settings(AUTH_COOKIE=auth_cookie_name, ROTATE_REFRESH_TOKENS=True): + res = self.client.post(reverse('token_refresh')) - res = client.post( - reverse('token_refresh'), - ) - self.assertEqual(res.status_code, 401) + self.assertEqual(res.status_code, 200) + # Make sure both tokens are updated + self.assertGreater(len(res.cookies.get(auth_cookie_name).value), 0) + self.assertGreater(len(res.cookies.get(auth_refresh_cookie_name).value), 0) + self.assertNotIn('access', res.data) + self.assertNotIn('refresh', res.data) + self.assertIn('csrf_token', res.data) + + # Get on test view again and test that it stills work after a refresh + with override_api_settings(AUTH_COOKIE=auth_cookie_name, AUTH_TOKEN_CLASSES=('rest_framework_simplejwt.tokens.AccessToken',)): + res = self.view_get() + + self.assertEqual(res.status_code, 200) + self.assertEqual(res.data['foo'], 'bar') + + # Try to post, it should fail because CSRF token is not in the header + with override_api_settings(AUTH_COOKIE=auth_cookie_name, AUTH_TOKEN_CLASSES=('rest_framework_simplejwt.tokens.AccessToken',)): + res = self.view_post(data={}) + self.assertEqual(res.status_code, 403) + + # Add CSRF + self.client.credentials(HTTP_X_CSRFTOKEN=csrf_token) + self.client.cookies[settings.CSRF_COOKIE_NAME] = csrf_token + with override_api_settings(AUTH_COOKIE=auth_cookie_name, AUTH_TOKEN_CLASSES=('rest_framework_simplejwt.tokens.AccessToken',)): + res = self.view_post(data={}) + + self.assertEqual(res.status_code, 200) + + # Delete cookies + with override_api_settings(AUTH_COOKIE=auth_cookie_name): + res = self.client.post(reverse('token_delete')) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.cookies.get(auth_cookie_name).value, '') + self.assertEqual(res.cookies.get(auth_refresh_cookie_name).value, '') + self.assertEqual(res.cookies.get(settings.CSRF_COOKIE_NAME).value, '') diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 4550fd22a..5769bf8bb 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,9 +1,8 @@ from datetime import timedelta from unittest.mock import MagicMock, patch -from django.test import TestCase from django.contrib.auth import get_user_model - +from django.test import TestCase from rest_framework import exceptions as drf_exceptions from rest_framework_simplejwt.exceptions import TokenError diff --git a/tests/test_tokens.py b/tests/test_tokens.py index 4a46ecf96..38a28f3d9 100644 --- a/tests/test_tokens.py +++ b/tests/test_tokens.py @@ -1,8 +1,8 @@ from datetime import datetime, timedelta from unittest.mock import patch -from django.test import TestCase from django.contrib.auth import get_user_model +from django.test import TestCase from jose import jwt from rest_framework_simplejwt.exceptions import TokenError diff --git a/tests/test_views.py b/tests/test_views.py index fba7de9d2..eac18147a 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -23,8 +23,8 @@ class TestTokenObtainPairView(APIViewTestCase): def setUp(self): self.username = 'test_user' self.password = 'test_password' - - self.user = get_user_model().objects.create_user( + self.user_model = get_user_model() + self.user = self.user_model.objects.create_user( username=self.username, password=self.password, ) @@ -32,20 +32,20 @@ def setUp(self): def test_fields_missing(self): res = self.view_post(data={}) self.assertEqual(res.status_code, 400) - self.assertIn(get_user_model().USERNAME_FIELD, res.data) + self.assertIn(self.user_model.USERNAME_FIELD, res.data) self.assertIn('password', res.data) - res = self.view_post(data={get_user_model().USERNAME_FIELD: self.username}) + res = self.view_post(data={self.user_model.USERNAME_FIELD: self.username}) self.assertEqual(res.status_code, 400) self.assertIn('password', res.data) res = self.view_post(data={'password': self.password}) self.assertEqual(res.status_code, 400) - self.assertIn(get_user_model().USERNAME_FIELD, res.data) + self.assertIn(self.user_model.USERNAME_FIELD, res.data) def test_credentials_wrong(self): res = self.view_post(data={ - get_user_model().USERNAME_FIELD: self.username, + self.user_model.USERNAME_FIELD: self.username, 'password': 'test_user', }) self.assertEqual(res.status_code, 401) @@ -56,7 +56,7 @@ def test_user_inactive(self): self.user.save() res = self.view_post(data={ - get_user_model().USERNAME_FIELD: self.username, + self.user_model.USERNAME_FIELD: self.username, 'password': self.password, }) self.assertEqual(res.status_code, 401) @@ -64,7 +64,7 @@ def test_user_inactive(self): def test_success(self): res = self.view_post(data={ - get_user_model().USERNAME_FIELD: self.username, + self.user_model.USERNAME_FIELD: self.username, 'password': self.password, }) self.assertEqual(res.status_code, 200) @@ -73,22 +73,22 @@ def test_success(self): def test_update_last_login(self): self.view_post(data={ - User.USERNAME_FIELD: self.username, + self.user_model.USERNAME_FIELD: self.username, 'password': self.password, }) # verify last_login is not updated - user = User.objects.get(username=self.username) + user = self.user_model.objects.get(username=self.username) self.assertEqual(user.last_login, None) # verify last_login is updated with override_api_settings(UPDATE_LAST_LOGIN=True): reload(serializers) self.view_post(data={ - User.USERNAME_FIELD: self.username, + self.user_model.USERNAME_FIELD: self.username, 'password': self.password, }) - user = User.objects.get(username=self.username) + user = self.user_model.USERNAME_FIELD.objects.get(username=self.username) self.assertIsNotNone(user.last_login) self.assertGreaterEqual(timezone.now(), user.last_login) @@ -101,8 +101,8 @@ class TestTokenRefreshView(APIViewTestCase): def setUp(self): self.username = 'test_user' self.password = 'test_password' - - self.user = get_user_model().objects.create_user( + self.user_model = get_user_model() + self.user = self.user_model.objects.create_user( username=self.username, password=self.password, ) @@ -152,8 +152,8 @@ class TestTokenObtainSlidingView(APIViewTestCase): def setUp(self): self.username = 'test_user' self.password = 'test_password' - - self.user = get_user_model().objects.create_user( + self.user_model = get_user_model() + self.user = self.user_model.objects.create_user( username=self.username, password=self.password, ) @@ -161,20 +161,20 @@ def setUp(self): def test_fields_missing(self): res = self.view_post(data={}) self.assertEqual(res.status_code, 400) - self.assertIn(get_user_model().USERNAME_FIELD, res.data) + self.assertIn(self.user_model.USERNAME_FIELD, res.data) self.assertIn('password', res.data) - res = self.view_post(data={get_user_model().USERNAME_FIELD: self.username}) + res = self.view_post(data={self.user_model.USERNAME_FIELD: self.username}) self.assertEqual(res.status_code, 400) self.assertIn('password', res.data) res = self.view_post(data={'password': self.password}) self.assertEqual(res.status_code, 400) - self.assertIn(get_user_model().USERNAME_FIELD, res.data) + self.assertIn(self.user_model.USERNAME_FIELD, res.data) def test_credentials_wrong(self): res = self.view_post(data={ - get_user_model().USERNAME_FIELD: self.username, + self.user_model.USERNAME_FIELD: self.username, 'password': 'test_user', }) self.assertEqual(res.status_code, 401) @@ -185,7 +185,7 @@ def test_user_inactive(self): self.user.save() res = self.view_post(data={ - get_user_model().USERNAME_FIELD: self.username, + self.user_model.USERNAME_FIELD: self.username, 'password': self.password, }) self.assertEqual(res.status_code, 401) @@ -193,7 +193,7 @@ def test_user_inactive(self): def test_success(self): res = self.view_post(data={ - get_user_model().USERNAME_FIELD: self.username, + self.user_model.USERNAME_FIELD: self.username, 'password': self.password, }) self.assertEqual(res.status_code, 200) @@ -201,22 +201,22 @@ def test_success(self): def test_update_last_login(self): self.view_post(data={ - User.USERNAME_FIELD: self.username, + self.user_model.USERNAME_FIELD: self.username, 'password': self.password, }) # verify last_login is not updated - user = User.objects.get(username=self.username) + user = self.user_model.objects.get(username=self.username) self.assertEqual(user.last_login, None) # verify last_login is updated with override_api_settings(UPDATE_LAST_LOGIN=True): reload(serializers) self.view_post(data={ - User.USERNAME_FIELD: self.username, + self.user_model.USERNAME_FIELD: self.username, 'password': self.password, }) - user = User.objects.get(username=self.username) + user = self.user_model.objects.get(username=self.username) self.assertIsNotNone(user.last_login) self.assertGreaterEqual(timezone.now(), user.last_login) @@ -229,8 +229,8 @@ class TestTokenRefreshSlidingView(APIViewTestCase): def setUp(self): self.username = 'test_user' self.password = 'test_password' - - self.user = get_user_model().objects.create_user( + self.user_model = get_user_model() + self.user = self.user_model.objects.create_user( username=self.username, password=self.password, ) @@ -294,8 +294,8 @@ class TestTokenVerifyView(APIViewTestCase): def setUp(self): self.username = 'test_user' self.password = 'test_password' - - self.user = get_user_model().objects.create_user( + self.user_model = get_user_model() + self.user = self.user_model.objects.create_user( username=self.username, password=self.password, ) @@ -321,7 +321,6 @@ def test_it_should_return_401_if_token_invalid(self): def test_it_should_return_200_if_everything_okay(self): token = RefreshToken() - res = self.view_post(data={'token': str(token)}) self.assertEqual(res.status_code, 200) self.assertEqual(len(res.data), 0) diff --git a/tests/utils.py b/tests/utils.py index ec6c7ea9f..768f53d8b 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -42,6 +42,7 @@ def authenticate_with_token(self, type, token): view_get = client_action_wrapper('get') +# Don't nest contexts it won't work!!! @contextlib.contextmanager def override_api_settings(**settings): old_settings = {} From 9360d2622c4bb23abdd2ef64fecfbd4b375b2d2a Mon Sep 17 00:00:00 2001 From: loicgasser Date: Mon, 19 Oct 2020 09:48:34 -0400 Subject: [PATCH 18/20] Rest outdated README to master --- README.rst | 590 ----------------------------------------------------- 1 file changed, 590 deletions(-) diff --git a/README.rst b/README.rst index 840dbbe53..1c3fdf830 100644 --- a/README.rst +++ b/README.rst @@ -17,596 +17,6 @@ Abstract Simple JWT is a JSON Web Token authentication plugin for the `Django REST Framework `__. -------------------------------------------------------------------------------- - -Simple JWT provides a JSON Web Token authentication backend for the Django REST -Framework. It aims to provide an out-of-the-box solution for JWT -authentication which avoids some of the common pitfalls of the JWT -specification. Assuming users of the library don't extensively and invasively -subclass everything, Simple JWT's behavior shouldn't be surprising. Settings -variable defaults should be safe. - -Requirements ------------- - -* Python (3.6, 3.7) -* Django (2.0, 2.1, 2.2) -* Django REST Framework (3.8, 3.9, 3.10) - -These are the officially supported python and package versions. Other versions -will probably work. You're free to modify the tox config and see what is -possible. - -Installation ------------- - -Simple JWT can be installed with pip:: - - pip install djangorestframework_simplejwt - -Then, your django project must be configured to use the library. In -``settings.py``, add -``rest_framework_simplejwt.authentication.JWTAuthentication`` to the list of -authentication classes: - -.. code-block:: python - - REST_FRAMEWORK = { - ... - 'DEFAULT_AUTHENTICATION_CLASSES': ( - ... - 'rest_framework_simplejwt.authentication.JWTAuthentication', - ) - ... - } - -Also, in your root ``urls.py`` file (or any other url config), include routes -for Simple JWT's ``TokenObtainPairView`` and ``TokenRefreshView`` views: - -.. code-block:: python - - from rest_framework_simplejwt.views import ( - TokenObtainPairView, - TokenRefreshView, - ) - - urlpatterns = [ - ... - url(r'^api/token/$', TokenObtainPairView.as_view(), name='token_obtain_pair'), - url(r'^api/token/refresh/$', TokenRefreshView.as_view(), name='token_refresh'), - ... - ] - -You can also include a route for Simple JWT's ``TokenVerifyView`` if you wish to -allow API users to verify HMAC-signed tokens without having access to your -signing key: - -.. code-block:: python - - urlpatterns = [ - ... - url(r'^api/token/verify/$', TokenVerifyView.as_view(), name='token_verify'), - ... - ] - -Usage ------ - -To verify that Simple JWT is working, you can use curl to issue a couple of -test requests: - -.. code-block:: bash - - curl \ - -X POST \ - -H "Content-Type: application/json" \ - -d '{"username": "davidattenborough", "password": "boatymcboatface"}' \ - http://localhost:8000/api/token/ - - ... - { - "access":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiY29sZF9zdHVmZiI6IuKYgyIsImV4cCI6MTIzNDU2LCJqdGkiOiJmZDJmOWQ1ZTFhN2M0MmU4OTQ5MzVlMzYyYmNhOGJjYSJ9.NHlztMGER7UADHZJlxNG0WSi22a2KaYSfd1S-AuT7lU", - "refresh":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImNvbGRfc3R1ZmYiOiLimIMiLCJleHAiOjIzNDU2NywianRpIjoiZGUxMmY0ZTY3MDY4NDI3ODg5ZjE1YWMyNzcwZGEwNTEifQ.aEoAYkSJjoWH1boshQAaTkf8G3yn0kapko6HFRt7Rh4" - } - -You can use the returned access token to prove authentication for a protected -view: - -.. code-block:: bash - - curl \ - -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiY29sZF9zdHVmZiI6IuKYgyIsImV4cCI6MTIzNDU2LCJqdGkiOiJmZDJmOWQ1ZTFhN2M0MmU4OTQ5MzVlMzYyYmNhOGJjYSJ9.NHlztMGER7UADHZJlxNG0WSi22a2KaYSfd1S-AuT7lU" \ - http://localhost:8000/api/some-protected-view/ - -When this short-lived access token expires, you can use the longer-lived -refresh token to obtain another access token: - -.. code-block:: bash - - curl \ - -X POST \ - -H "Content-Type: application/json" \ - -d '{"refresh":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImNvbGRfc3R1ZmYiOiLimIMiLCJleHAiOjIzNDU2NywianRpIjoiZGUxMmY0ZTY3MDY4NDI3ODg5ZjE1YWMyNzcwZGEwNTEifQ.aEoAYkSJjoWH1boshQAaTkf8G3yn0kapko6HFRt7Rh4"}' \ - http://localhost:8000/api/token/refresh/ - - ... - {"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', - } - -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``: - -.. code-block:: python - - urlpatterns = [ - ... - path('api/token/delete/', TokenCookieDeleteView.as_view(), name='token_delete'), - ... - ] - -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. - -Usage ------ - -To verify that cookies are working, you can use curl to issue a couple of test requests: - -.. 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 returned csrftoken cookie value from cookies.txt file (while using curl) 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 --------- - -Some of Simple JWT's behavior can be customized through settings variables in -``settings.py``: - -.. code-block:: python - - # Django project settings.py - - from datetime import timedelta - - ... - - SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), - 'ROTATE_REFRESH_TOKENS': False, - 'BLACKLIST_AFTER_ROTATION': True, - - 'ALGORITHM': 'HS256', - 'SIGNING_KEY': settings.SECRET_KEY, - 'VERIFYING_KEY': None, - - 'AUTH_HEADER_TYPES': ('Bearer',), - 'USER_ID_FIELD': 'id', - 'USER_ID_CLAIM': 'user_id', - - 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), - 'TOKEN_TYPE_CLAIM': 'token_type', - - 'JTI_CLAIM': 'jti', - - '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. - -------------------------------------------------------------------------------- - -ACCESS_TOKEN_LIFETIME - A ``datetime.timedelta`` object which specifies how long access tokens are - valid. This ``timedelta`` value is added to the current UTC time during - token generation to obtain the token's default "exp" claim value. - -REFRESH_TOKEN_LIFETIME - A ``datetime.timedelta`` object which specifies how long refresh tokens are - valid. This ``timedelta`` value is added to the current UTC time during - token generation to obtain the token's default "exp" claim value. - -ROTATE_REFRESH_TOKENS - When set to ``True``, if a refresh token is submitted to the - ``TokenRefreshView``, a new refresh token will be returned along with the new - access token. This new refresh token will be supplied via a "refresh" key in - the JSON response. New refresh tokens will have a renewed expiration time - which is determined by adding the timedelta in the ``REFRESH_TOKEN_LIFETIME`` - setting to the current time when the request is made. If the blacklist app - is in use and the ``BLACKLIST_AFTER_ROTATION`` setting is set to ``True``, - refresh tokens submitted to the refresh view will be added to the blacklist. - -BLACKLIST_AFTER_ROTATION - When set to ``True``, causes refresh tokens submitted to the - ``TokenRefreshView`` to be added to the blacklist if the blacklist app is in - use and the ``ROTATE_REFRESH_TOKENS`` setting is set to ``True``. - -ALGORITHM - The algorithm from the PyJWT library which will be used to perform - signing/verification operations on tokens. To use symmetric HMAC signing and - verification, the following algorithms may be used: ``'HS256'``, ``'HS384'``, - ``'HS512'``. When an HMAC algorithm is chosen, the ``SIGNING_KEY`` setting - will be used as both the signing key and the verifying key. In that case, - the ``VERIFYING_KEY`` setting will be ignored. To use asymmetric RSA signing - and verification, the following algorithms may be used: ``'RS256'``, - ``'RS384'``, ``'RS512'``. When an RSA algorithm is chosen, the - ``SIGNING_KEY`` setting must be set to a string which contains an RSA private - key. Likewise, the ``VERIFYING_KEY`` setting must be set to a string which - contains an RSA public key. - -SIGNING_KEY - The signing key which is used to sign the content of generated tokens. For - HMAC signing, this should be a random string with at least as many bits of - data as is required by the signing protocol. For RSA signing, this - should be a string which contains an RSA private key which is 2048 bits or - longer. Since Simple JWT defaults to using 256-bit HMAC signing, the - ``SIGNING_KEY`` setting defaults to the value of the ``SECRET_KEY`` setting - for your django project. Although this is the most reasonable default that - Simple JWT can provide, it is recommended that developers change this setting - to a value which is independent from the django project secret key. This - will make changing the signing key used for tokens easier in the event that - it is compromised. - -VERIFYING_KEY - The verifying key which is used to verify the content of generated tokens. - If an HMAC algorithm has been specified by the ``ALGORITHM`` setting, the - ``VERIFYING_KEY`` setting will be ignored and the value of the - ``SIGNING_KEY`` setting will be used. If an RSA algorithm has been specified - by the ``ALGORITHM`` setting, the ``VERIFYING_KEY`` setting must be set to a - string which contains an RSA public key. - -AUTH_HEADER_TYPES - The authorization header type(s) that will be accepted for views that require - authentication. For example, a value of ``'Bearer'`` means that views - requiring authentication would look for a header with the following format: - ``Authorization: Bearer ``. This setting may also contain a list or - tuple of possible header types (e.g. ``('Bearer', 'JWT')``). If a list or - tuple is used in this way, and authentication fails, the first item in the - collection will be used to build the "WWW-Authenticate" header in the - response. - -USER_ID_FIELD - The database field from the user model that will be included in generated - tokens to identify users. It is recommended that the value of this setting - specifies a field which does not normally change once its initial value is - chosen. For example, specifying a "username" or "email" field would be a - poor choice since an account's username or email might change depending on - how account management in a given service is designed. This could allow a - new account to be created with an old username while an existing token is - still valid which uses that username as a user identifier. - -USER_ID_CLAIM - The claim in generated tokens which will be used to store user identifiers. - For example, a setting value of ``'user_id'`` would mean generated tokens - include a "user_id" claim that contains the user's identifier. - -AUTH_TOKEN_CLASSES - A list of dot paths to classes which specify the types of token that are - allowed to prove authentication. More about this in the "Token types" - section below. - -TOKEN_TYPE_CLAIM - The claim name that is used to store a token's type. More about this in the - "Token types" section below. - -JTI_CLAIM - The claim name that is used to store a token's unique identifier. This - identifier is used to identify revoked tokens in the blacklist app. It may - be necessary in some cases to use another claim besides the default "jti" - claim to store such a value. - -SLIDING_TOKEN_LIFETIME - A ``datetime.timedelta`` object which specifies how long sliding tokens are - valid to prove authentication. This ``timedelta`` value is added to the - current UTC time during token generation to obtain the token's default "exp" - claim value. More about this in the "Sliding tokens" section below. - -SLIDING_TOKEN_REFRESH_LIFETIME - A ``datetime.timedelta`` object which specifies how long sliding tokens are - valid to be refreshed. This ``timedelta`` value is added to the current UTC - time during token generation to obtain the token's default "exp" claim value. - More about this in the "Sliding tokens" section below. - -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 ------------------------- - -If you wish to customize the claims contained in web tokens which are generated -by the ``TokenObtainPairView`` and ``TokenObtainSlidingView`` views, create a -subclass for the desired view as well as a subclass for its corresponding -serializer. Here's an example of how to customize the claims in tokens -generated by the ``TokenObtainPairView``: - -.. code-block:: python - - from rest_framework_simplejwt.serializers import TokenObtainPairSerializer - from rest_framework_simplejwt.views import TokenObtainPairView - - class MyTokenObtainPairSerializer(TokenObtainPairSerializer): - @classmethod - def get_token(cls, user): - token = super().get_token(user) - - # Add custom claims - token['name'] = user.name - # ... - - return token - - class MyTokenObtainPairView(TokenObtainPairView): - serializer_class = MyTokenObtainPairSerializer - -Note that the example above will cause the customized claims to be present in -both refresh *and* access tokens which are generated by the view. This follows -from the fact that the ``get_token`` method above produces the *refresh* token -for the view, which is in turn used to generate the view's access token. - -As with the standard token views, you'll also need to include a url route to -your subclassed view. - -Creating tokens manually ------------------------- - -Sometimes, you may wish to manually create a token for a user. This could be -done as follows: - -.. code-block:: python - - from rest_framework_simplejwt.tokens import RefreshToken - - def get_tokens_for_user(user): - refresh = RefreshToken.for_user(user) - - return { - 'refresh': str(refresh), - 'access': str(refresh.access_token), - } - -The above function ``get_tokens_for_user`` will return the serialized -representations of new refresh and access tokens for the given user. In -general, a token for any subclass of ``rest_framework_simplejwt.tokens.Token`` -can be created in this way. - -Token types ------------ - -Simple JWT provides two different token types which can be used to prove -authentication. In a token's payload, its type can be identified by the value -of its token type claim, which is "token_type" by default. This may have a -value of "access", "sliding", or "refresh" however refresh tokens are not -considered valid for authentication at this time. The claim name used to store -the type can be customized by changing the ``TOKEN_TYPE_CLAIM`` setting. - -By default, Simple JWT expects an "access" token to prove authentication. The -allowed auth token types are determined by the value of the -``AUTH_TOKEN_CLASSES`` setting. This setting contains a list of dot paths to -token classes. It includes the -``'rest_framework_simplejwt.tokens.AccessToken'`` dot path by default but may -also include the ``'rest_framework_simplejwt.tokens.SlidingToken'`` dot path. -Either or both of those dot paths may be present in the list of auth token -classes. If they are both present, then both of those token types may be used -to prove authentication. - -Sliding tokens --------------- - -Sliding tokens offer a more convenient experience to users of tokens with the -trade-offs of being less secure and, in the case that the blacklist app is -being used, less performant. A sliding token is one which contains both an -expiration claim and a refresh expiration claim. As long as the timestamp in a -sliding token's expiration claim has not passed, it can be used to prove -authentication. Additionally, as long as the timestamp in its refresh -expiration claim has not passed, it may also be submitted to a refresh view to -get another copy of itself with a renewed expiration claim. - -If you want to use sliding tokens, change the ``AUTH_TOKEN_CLASSES`` setting to -``('rest_framework_simplejwt.tokens.SlidingToken',)``. (Alternatively, the -``AUTH_TOKEN_CLASSES`` setting may include dot paths to both the -``AccessToken`` and ``SlidingToken`` token classes in the -``rest_framework_simplejwt.tokens`` module if you want to allow both token -types to be used for authentication.) - -Also, include urls for the sliding token specific ``TokenObtainSlidingView`` -and ``TokenRefreshSlidingView`` views along side or in place of urls for the -access token specific ``TokenObtainPairView`` and ``TokenRefreshView`` views: - -.. code-block:: python - - from rest_framework_simplejwt.views import ( - TokenObtainSlidingView, - TokenRefreshSlidingView, - ) - - urlpatterns = [ - ... - url(r'^api/token/$', TokenObtainSlidingView.as_view(), name='token_obtain'), - url(r'^api/token/refresh/$', TokenRefreshSlidingView.as_view(), name='token_refresh'), - ... - ] - -Be aware that, if you are using the blacklist app, Simple JWT will validate all -sliding tokens against the blacklist for each authenticated request. This will -reduce the performance of authenticated API views. - -Blacklist app -------------- - -Simple JWT includes an app that provides token blacklist functionality. To use -this app, include it in your list of installed apps in ``settings.py``: - -.. code-block:: python - - # Django project settings.py - - ... - - INSTALLED_APPS = ( - ... - 'rest_framework_simplejwt.token_blacklist', - ... - } - -Also, make sure to run ``python manage.py migrate`` to run the app's -migrations. - -If the blacklist app is detected in ``INSTALLED_APPS``, Simple JWT will add any -generated refresh or sliding tokens to a list of outstanding tokens. It will -also check that any refresh or sliding token does not appear in a blacklist of -tokens before it considers it as valid. - -The Simple JWT blacklist app implements its outstanding and blacklisted token -lists using two models: ``OutstandingToken`` and ``BlacklistedToken``. Model -admins are defined for both of these models. To add a token to the blacklist, -find its corresponding ``OutstandingToken`` record in the admin and use the -admin again to create a ``BlacklistedToken`` record that points to the -``OutstandingToken`` record. - -Alternatively, you can blacklist a token by creating a ``BlacklistMixin`` -subclass instance and calling the instance's ``blacklist`` method: - -.. code-block:: python - - from rest_framework_simplejwt.tokens import RefreshToken - - token = RefreshToken(base64_encoded_token_string) - token.blacklist() - -This will create unique outstanding token and blacklist records for the token's -"jti" claim or whichever claim is specified by the ``JTI_CLAIM`` setting. - -The blacklist app also provides a management command, ``flushexpiredtokens``, -which will delete any tokens from the outstanding list and blacklist that have -expired. You should set up a cron job on your server or hosting platform which -runs this command daily. - -Experimental features ---------------------- - -JWTTokenUserAuthentication backend - The ``JWTTokenUserAuthentication`` backend's ``authenticate`` method does not - perform a database lookup to obtain a user instance. Instead, it returns a - ``rest_framework_simplejwt.models.TokenUser`` instance which acts as a - stateless user object backed only by a validated token instead of a record in - a database. This can facilitate developing single sign-on functionality - between separately hosted Django apps which all share the same token secret - key. To use this feature, add the - ``rest_framework_simplejwt.authentication.JWTTokenUserAuthentication`` - backend (instead of the default ``JWTAuthentication`` backend) to the Django - REST Framework's ``DEFAULT_AUTHENTICATION_CLASSES`` config setting: - - .. code-block:: python - - REST_FRAMEWORK = { - ... - 'DEFAULT_AUTHENTICATION_CLASSES': ( - ... - 'rest_framework_simplejwt.authentication.JWTTokenUserAuthentication', - ) - ... - } - -Development and Running the Tests ---------------------------------- - -To do development work for Simple JWT, make your own fork on Github, clone it -locally, make and activate a virtualenv for it, then from within the project -directory: - -.. code-block:: bash - - pip install --upgrade pip setuptools - pip install -e .[dev] - -To run the tests: - -.. code-block:: bash - - pytest - -To run the tests in all supported environments with tox, first `install pyenv -`__. Next, install the relevant -Python minor versions and create a ``.python-version`` file in the project -directory: - -.. code-block:: bash - - pyenv install 3.7.x - pyenv install 3.6.x - pyenv install 3.5.x - cat > .python-version <`__. From 2a14612d78c94da10fe82141f123c59e2a4d7922 Mon Sep 17 00:00:00 2001 From: loicgasser Date: Mon, 19 Oct 2020 10:04:26 -0400 Subject: [PATCH 19/20] Fix rebase error --- tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_views.py b/tests/test_views.py index eac18147a..e7b03ef39 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -88,7 +88,7 @@ def test_update_last_login(self): self.user_model.USERNAME_FIELD: self.username, 'password': self.password, }) - user = self.user_model.USERNAME_FIELD.objects.get(username=self.username) + user = self.user_model.objects.get(username=self.username) self.assertIsNotNone(user.last_login) self.assertGreaterEqual(timezone.now(), user.last_login) From 71ddc0dd82e9a9b30226661b5a4d54e1d3935bba Mon Sep 17 00:00:00 2001 From: loicgasser Date: Mon, 19 Oct 2020 12:59:20 -0400 Subject: [PATCH 20/20] CSRF_COOKIE_SAMESITE was introduced in django 2.1 --- rest_framework_simplejwt/settings.py | 9 ++++++--- rest_framework_simplejwt/views.py | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/rest_framework_simplejwt/settings.py b/rest_framework_simplejwt/settings.py index 5b021aa50..6b951d947 100644 --- a/rest_framework_simplejwt/settings.py +++ b/rest_framework_simplejwt/settings.py @@ -46,11 +46,14 @@ 'AUTH_COOKIE_SECURE': settings.CSRF_COOKIE_SECURE, # The path of the auth cookie. 'AUTH_COOKIE_PATH': settings.CSRF_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': settings.CSRF_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. 'None' is supported in version 3.1 only +# CSRF_COOKIE_SAMESITE was introduced in django 2.1 https://docs.djangoproject.com/en/3.1/releases/2.1/#csrf +if hasattr(settings, 'CSRF_COOKIE_SAMESITE'): + DEFAULTS['AUTH_COOKIE_SAMESITE'] = settings.CSRF_COOKIE_SAMESITE + IMPORT_STRINGS = ( 'AUTH_TOKEN_CLASSES', 'TOKEN_USER_CLASS', diff --git a/rest_framework_simplejwt/views.py b/rest_framework_simplejwt/views.py index 59e508444..a87bfdafe 100644 --- a/rest_framework_simplejwt/views.py +++ b/rest_framework_simplejwt/views.py @@ -51,14 +51,17 @@ def post(self, request, *args, **kwargs): return Response(data, status=status.HTTP_200_OK) def get_cookie_data(self): - return { + cookie_data = { 'expires': self.get_refresh_token_expiration(), '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 + 'httponly': True } + # prior to django 2.1 samesite was not supported + if hasattr(api_settings, 'AUTH_COOKIE_SAMESITE'): + cookie_data['samesite'] = api_settings.AUTH_COOKIE_SAMESITE + return cookie_data def set_auth_cookies(self, response, data, cookie_data): return response