diff --git a/README.rst b/README.rst index 1c3fdf830..4ee407554 100644 --- a/README.rst +++ b/README.rst @@ -12,11 +12,602 @@ Simple JWT .. image:: https://readthedocs.org/projects/django-rest-framework-simplejwt/badge/?version=latest :target: https://django-rest-framework-simplejwt.readthedocs.io/en/latest/ + +*Disclaimer:* This is a Jyve fork of `David Sanders' (@davesque) Simple JWT `__. +We have merged in `an open PR from @NiyazNz `__ which adds httpOnly cookie storage. + + Abstract -------- Simple JWT is a JSON Web Token authentication plugin for the `Django REST -Framework `__. +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 <`__. diff --git a/rest_framework_simplejwt/authentication.py b/rest_framework_simplejwt/authentication.py index 19243d5e8..35935454f 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,21 @@ ) +def enforce_csrf(request): + """ + Enforce CSRF validation. + """ + def dummy_get_response(request): # pragma: no cover + return None + + check = CSRFCheck(dummy_get_response) # 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 @@ -26,15 +42,25 @@ 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 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/backends.py b/rest_framework_simplejwt/backends.py index b75886f55..8895c2526 100644 --- a/rest_framework_simplejwt/backends.py +++ b/rest_framework_simplejwt/backends.py @@ -40,7 +40,7 @@ def encode(self, payload): jwt_payload['iss'] = self.issuer token = jwt.encode(jwt_payload, self.signing_key, algorithm=self.algorithm) - return token.decode('utf-8') + return token def decode(self, token, verify=True): """ diff --git a/rest_framework_simplejwt/settings.py b/rest_framework_simplejwt/settings.py index 9b306952a..4f2305061 100644 --- a/rest_framework_simplejwt/settings.py +++ b/rest_framework_simplejwt/settings.py @@ -34,6 +34,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..1cea6a2b3 100644 --- a/rest_framework_simplejwt/views.py +++ b/rest_framework_simplejwt/views.py @@ -1,6 +1,15 @@ +from datetime import datetime + +from django.middleware import csrf +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 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 +37,69 @@ 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: + csrf.get_token(self.request) + response = self.set_auth_cookies(response, serializer.validated_data) + + return response + + def set_auth_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: + 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: + 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): + 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(self.token_refresh_view_name), + secure=api_settings.AUTH_COOKIE_SECURE or None, + httponly=True, + samesite=api_settings.AUTH_COOKIE_SAMESITE, + ) + 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 +110,48 @@ 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): + """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): + 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 +162,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 +182,36 @@ class TokenVerifyView(TokenViewBase): token_verify = TokenVerifyView.as_view() + + +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 = () + + def post(self, request): + response = Response({}) + + if api_settings.AUTH_COOKIE: + self.delete_auth_cookies(response) + + return response + + def delete_auth_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(self.token_refresh_view_name), + ) + + +token_delete = TokenCookieDeleteView.as_view() diff --git a/setup.py b/setup.py index f5f8b5edd..dbb714f6f 100755 --- a/setup.py +++ b/setup.py @@ -44,8 +44,8 @@ setup( - name='djangorestframework_simplejwt', - version='4.4.0', + name='jyve-djangorestframework_simplejwt', + version='4.4.0a2', url='https://github.com/SimpleJWT/django-rest-framework-simplejwt', license='MIT', description='A minimal JSON Web Token authentication plugin for Django REST Framework', diff --git a/tests/test_integration.py b/tests/test_integration.py index 7d2db2edc..2f4b260c2 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,10 +1,12 @@ 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 from rest_framework_simplejwt.tokens import AccessToken - from .utils import APIViewTestCase, override_api_settings @@ -84,6 +86,63 @@ 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={ + User.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'), @@ -118,3 +177,58 @@ 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'), + data={ + User.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'), + ) + + 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'), + ) + self.assertEqual(res.status_code, 401) diff --git a/tests/test_views.py b/tests/test_views.py index 3c05568c0..2adc6f0ea 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -10,7 +10,7 @@ aware_utcnow, datetime_from_epoch, datetime_to_epoch, ) -from .utils import APIViewTestCase +from .utils import APIViewTestCase, override_api_settings class TestTokenObtainPairView(APIViewTestCase): @@ -67,6 +67,15 @@ def test_success(self): self.assertIn('access', res.data) self.assertIn('refresh', res.data) + with override_api_settings(AUTH_COOKIE='authorization'): + res = self.view_post(data={ + User.USERNAME_FIELD: self.username, + 'password': self.password, + }) + self.assertEqual(res.status_code, 200) + self.assertIn('authorization', res.cookies) + self.assertIn('authorization_refresh', res.cookies) + class TestTokenRefreshView(APIViewTestCase): view_name = 'token_refresh' @@ -172,6 +181,14 @@ def test_success(self): self.assertEqual(res.status_code, 200) self.assertIn('token', res.data) + with override_api_settings(AUTH_COOKIE='authorization'): + res = self.view_post(data={ + User.USERNAME_FIELD: self.username, + 'password': self.password, + }) + self.assertEqual(res.status_code, 200) + self.assertIn('authorization', res.cookies) + class TestTokenRefreshSlidingView(APIViewTestCase): view_name = 'token_refresh_sliding' diff --git a/tests/urls.py b/tests/urls.py index 04f105641..4c4d2dcdf 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -13,5 +13,7 @@ url(r'^token/verify/$', jwt_views.token_verify, name='token_verify'), + url(r'^token/delete/$', jwt_views.token_delete, name='token_delete'), + url(r'^test-view/$', views.test_view, name='test_view'), ] 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()