diff --git a/docs/index.md b/docs/index.md index b5300ec4..ee5e4515 100644 --- a/docs/index.md +++ b/docs/index.md @@ -101,6 +101,7 @@ Refresh with tokens can be repeated (token1 -> token2 -> token3), but this chain A typical use case might be a web app where you'd like to keep the user "logged in" the site without having to re-enter their password, or get kicked out by surprise before their token expired. Imagine they had a 1-hour token and are just at the last minute while they're still doing something. With mobile you could perhaps store the username/password to get a new token, but this is not a great idea in a browser. Each time the user loads the page, you can check if there is an existing non-expired token and if it's close to being expired, refresh it to extend their session. In other words, if a user is actively using your site, they can keep their "session" alive. + ## Verify Token In some microservice architectures, authentication is handled by a single service. Other services delegate the responsibility of confirming that a user is logged in to this authentication service. This usually means that a service will pass a JWT received from the user to the authentication service, and wait for a confirmation that the JWT is valid before returning protected resources to the user. @@ -116,6 +117,30 @@ Passing a token to the verification endpoint will return a 200 response and the $ curl -X POST -H "Content-Type: application/json" -d '{"token":""}' http://localhost:8000/api-token-verify/ ``` +## Blacklist Token +If `rest_framework_jwt.blacklist` is added to `settings.INSTALLED_APPS`, tokens can be made invalid (prior to expiration) by blacklisting them. More information on the concept of JTI (JWT ID) and blacklisting tokens can be read [here](http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html#jtiDef) and [here](https://auth0.com/blog/2015/03/10/blacklist-json-web-token-api-keys/). + +This package comes with a default implementation that stores the blacklisted tokens in the configured django database and includes an admin integration. + +To use this feature, add a URL like so: + +```python + url(r'^api-token-blacklist/', 'rest_framework_jwt.blacklist.views.blacklist_jwt_token'), +``` + +Now to blacklist a token, send a POST request with a non-expired token to the blacklist endpoint: + +```bash +$ curl -X POST -H "Content-Type: application/json" -d '{"token":""}' http://localhost:8000/api-token-blacklist/ +``` + +If the blacklisting was successful, the response will contain the default implementation response data which is the token and a success message. Any future requests using that token will be denied. + +The typical use case for this feature is forcefully logging a user out due to inactivity. Many applications, especially ones with sensitive information, may implement an activity-based countdown timer and wish to instantly inactivate the user's auth token. The default implementation stores a record in the database with the unique JTI (JWT ID). However there are configurable handlers for getting and setting the blacklisted token which leaves it up to the user to decide how or where they are stored. The only requirements are that both `JWT_BLACKLIST_GET_HANDLER` and `JWT_BLACKLIST_SET_HANDLER` return a valid blacklisted token or None. + +**Note** +Applications that are built with a Serivce-Oriented-Architecture (SOA) may not be able to use the blacklist token feature due to having to query the auth service on every request to check if the JWT is blacklisted. + ## Additional Settings There are some additional settings that you can override similar to how you'd do it with Django REST framework itself. Here are all the available defaults. @@ -136,6 +161,15 @@ JWT_AUTH = { 'JWT_RESPONSE_PAYLOAD_HANDLER': 'rest_framework_jwt.utils.jwt_response_payload_handler', + 'JWT_BLACKLIST_GET_HANDLER': + 'rest_framework_jwt.blacklist.utils.jwt_blacklist_get_handler', + + 'JWT_BLACKLIST_SET_HANDLER': + 'rest_framework_jwt.blacklist.utils.jwt_blacklist_set_handler', + + 'JWT_BLACKLIST_RESPONSE_HANDLER': + 'rest_framework_jwt.blacklist.utils.jwt_blacklist_response_handler', + 'JWT_SECRET_KEY': settings.SECRET_KEY, 'JWT_ALGORITHM': 'HS256', 'JWT_VERIFY': True, @@ -149,6 +183,8 @@ JWT_AUTH = { 'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=7), 'JWT_AUTH_HEADER_PREFIX': 'JWT', + + 'JWT_ENABLE_BLACKLIST': False, } ``` This packages uses the JSON Web Token Python implementation, [PyJWT](https://github.com/jpadilla/pyjwt) and allows to modify some of it's available options. @@ -208,6 +244,9 @@ Default is `datetime.timedelta(days=7)` (7 days). ### JWT_PAYLOAD_HANDLER Specify a custom function to generate the token payload +**Note** +If you have `rest_framework_jwt.blacklist` added to `INSTALLED_APPS` *AND* you are using the default blacklist implementation, any custom token payload must include both a `jti` attribute which is a unique UUID hex, and a `exp` attribute which is a timestamp from a POSIX time (e.g. seconds since epoch). + ### JWT_PAYLOAD_GET_USER_ID_HANDLER If you store `user_id` differently than the default payload handler does, implement this function to fetch `user_id` from the payload. @@ -227,6 +266,50 @@ def jwt_response_payload_handler(token, user=None, request=None): Default is `{'token': token}` +### JWT_BLACKLIST_GET_HANDLER +Responsible for fetching a blacklisted JWT token. This function should return either a valid blacklisted token or None. + +The default included implementation is as follows: +``` +def jwt_blacklist_get_handler(payload): + jti = payload.get('jti') + try: + token = models.JWTBlackListToken.objects.get(jti=jti) + except models.JWTBlackListToken.DoesNotExist: + return None + else: + return token +``` + +### JWT_BLACKLIST_SET_HANDLER +Responsible for setting a blacklisted JWT token. This function should return either a valid blacklisted token or None. + +The default included implementation is as follows: +``` +def jwt_blacklist_set_handler(payload): + try: + data = { + 'jti': payload.get('jti'), + 'created': now(), + 'expires': datetime.fromtimestamp(payload.get('exp')) + } + return models.JWTBlackListToken.objects.create(**data) + except (TypeError, IntegrityError, Exception): + return None +``` + +### JWT_BLACKLIST_RESPONSE_HANDLER +Controls what the response data for a request to the JWT blacklist endpoint returns. + +The default implementation is as follows: +``` +def jwt_blacklist_response_handler(token, user=None, request=None): + return { + 'token': JWTBlackListTokenSerializer(token).data, + 'message': 'Token successfully blacklisted.' + } +``` + ### JWT_AUTH_HEADER_PREFIX You can modify the Authorization header value prefix that is required to be sent together with the token. The default value is `JWT`. This decision was introduced in PR [#4](https://github.com/GetBlimp/django-rest-framework-jwt/pull/4) to allow using both this package and OAuth2 in DRF. diff --git a/rest_framework_jwt/authentication.py b/rest_framework_jwt/authentication.py index 64d5f9a5..b36c842d 100644 --- a/rest_framework_jwt/authentication.py +++ b/rest_framework_jwt/authentication.py @@ -1,6 +1,9 @@ import jwt + +from django.conf import settings from django.utils.encoding import smart_text from django.utils.translation import ugettext as _ + from rest_framework import exceptions from rest_framework.authentication import (BaseAuthentication, get_authorization_header) @@ -11,6 +14,7 @@ jwt_decode_handler = api_settings.JWT_DECODE_HANDLER jwt_get_user_id_from_payload = api_settings.JWT_PAYLOAD_GET_USER_ID_HANDLER +jwt_blacklist_get_handler = api_settings.JWT_BLACKLIST_GET_HANDLER class BaseJSONWebTokenAuthentication(BaseAuthentication): @@ -38,6 +42,22 @@ def authenticate(self, request): except jwt.InvalidTokenError: raise exceptions.AuthenticationFailed() + # Check if the token has been blacklisted. + if 'rest_framework_jwt.blacklist' in settings.INSTALLED_APPS: + blacklisted = jwt_blacklist_get_handler(payload) + + if blacklisted: + msg = _('Token is blacklisted.') + raise exceptions.AuthenticationFailed(msg) + + # Check if the token has been blacklisted. + if 'rest_framework_jwt.blacklist' in settings.INSTALLED_APPS: + blacklisted = jwt_blacklist_get_handler(payload) + + if blacklisted: + msg = _('Token is blacklisted.') + raise exceptions.AuthenticationFailed(msg) + user = self.authenticate_credentials(payload) return (user, jwt_value) diff --git a/rest_framework_jwt/blacklist/__init__.py b/rest_framework_jwt/blacklist/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rest_framework_jwt/blacklist/admin.py b/rest_framework_jwt/blacklist/admin.py new file mode 100644 index 00000000..5153b4d7 --- /dev/null +++ b/rest_framework_jwt/blacklist/admin.py @@ -0,0 +1,18 @@ +from django.conf import settings +from django.contrib import admin + +from . import models + + +class JWTBlacklistTokenAdmin(admin.ModelAdmin): + list_display = ('jti', 'expires', 'created', 'is_active') + fields = ('jti', 'expires', 'created', 'is_active') + readonly_fields = ('jti', 'expires', 'created', 'is_active') + + def is_active(self, obj): + return obj.is_active() + is_active.boolean = True + is_active.short_description = 'Active' + +if 'rest_framework_jwt.blacklist' in settings.INSTALLED_APPS: + admin.site.register(models.JWTBlacklistToken, JWTBlacklistTokenAdmin) diff --git a/rest_framework_jwt/blacklist/models.py b/rest_framework_jwt/blacklist/models.py new file mode 100644 index 00000000..af3665ef --- /dev/null +++ b/rest_framework_jwt/blacklist/models.py @@ -0,0 +1,20 @@ +from django.db import models +from django.utils.timezone import now +from django.utils.translation import ugettext_lazy as _ + +from rest_framework_jwt.compat import get_uuid_field + +UUIDField = get_uuid_field() + + +class JWTBlacklistToken(models.Model): + jti = UUIDField() + expires = models.DateTimeField() + created = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = _('JWT Blacklist Token') + verbose_name_plural = _('JWT Blacklist Tokens') + + def is_active(self): + return self.expires > now() diff --git a/rest_framework_jwt/blacklist/serializers.py b/rest_framework_jwt/blacklist/serializers.py new file mode 100644 index 00000000..591e4e65 --- /dev/null +++ b/rest_framework_jwt/blacklist/serializers.py @@ -0,0 +1,45 @@ +from django.conf import settings +from django.utils.translation import ugettext as _ + +from rest_framework import serializers + +from rest_framework_jwt.settings import api_settings +from rest_framework_jwt.serializers import VerificationBaseSerializer + +from . import models + +jwt_blacklist_set_handler = api_settings.JWT_BLACKLIST_SET_HANDLER + + +class BlacklistJSONWebTokenSerializer(VerificationBaseSerializer): + """ + Blacklist an access token. + """ + def validate(self, attrs): + + token = attrs['token'] + + if 'rest_framework_jwt.blacklist' not in settings.INSTALLED_APPS: + msg = _('The blacklist app is not installed.') + raise serializers.ValidationError(msg) + + payload = self._check_payload(token=token) + + # Handle blacklisting a token. + token = jwt_blacklist_set_handler(payload) + + if not token: + msg = _('Could not blacklist token.') + raise serializers.ValidationError(msg) + + user = self._check_user(payload=payload) + + return { + 'token': token, + 'user': user + } + + +class JWTBlacklistTokenSerializer(serializers.ModelSerializer): + class Meta: + model = models.JWTBlacklistToken diff --git a/rest_framework_jwt/blacklist/utils.py b/rest_framework_jwt/blacklist/utils.py new file mode 100644 index 00000000..bbe7c387 --- /dev/null +++ b/rest_framework_jwt/blacklist/utils.py @@ -0,0 +1,52 @@ +from datetime import datetime + +from django.db import IntegrityError +from django.utils.timezone import now +from django.utils.translation import ugettext_lazy as _ + +from . import models + + +def jwt_blacklist_get_handler(payload): + """ + Default implementation to check if a blacklisted jwt token exists. + + Should return a black listed token or None. + """ + jti = payload.get('jti') + + try: + token = models.JWTBlacklistToken.objects.get(jti=jti) + except models.JWTBlacklistToken.DoesNotExist: + return None + else: + return token + + +def jwt_blacklist_set_handler(payload): + """ + Default implementation that blacklists a jwt token. + + Should return a black listed token or None. + """ + try: + data = { + 'jti': payload.get('jti'), + 'created': now(), + 'expires': datetime.fromtimestamp(payload.get('exp')) + } + return models.JWTBlacklistToken.objects.create(**data) + except (TypeError, IntegrityError, Exception): + return None + + +def jwt_blacklist_response_handler(token, user=None, request=None): + """ + Default blacklist token response data. Override to provide a + custom response. + """ + from . import serializers + return { + 'token': serializers.JWTBlacklistTokenSerializer(token).data, + 'message': _('Token successfully blacklisted.') + } diff --git a/rest_framework_jwt/blacklist/views.py b/rest_framework_jwt/blacklist/views.py new file mode 100644 index 00000000..d5c75f81 --- /dev/null +++ b/rest_framework_jwt/blacklist/views.py @@ -0,0 +1,19 @@ +from rest_framework_jwt.settings import api_settings +from rest_framework_jwt.views import JSONWebTokenAPIView + +from . import serializers + +jwt_blacklist_response_handler = api_settings.JWT_BLACKLIST_RESPONSE_HANDLER + + +class BlacklistJSONWebToken(JSONWebTokenAPIView): + """ + API View that blacklists a token + """ + serializer_class = serializers.BlacklistJSONWebTokenSerializer + response_payload_handler = staticmethod( + jwt_blacklist_response_handler + ) + + +blacklist_jwt_token = BlacklistJSONWebToken.as_view() diff --git a/rest_framework_jwt/compat.py b/rest_framework_jwt/compat.py index 01313aae..02f9f06c 100644 --- a/rest_framework_jwt/compat.py +++ b/rest_framework_jwt/compat.py @@ -1,11 +1,29 @@ import rest_framework + +from django.db import models + from distutils.version import StrictVersion +from functools import partial + +from rest_framework import serializers if StrictVersion(rest_framework.VERSION) < StrictVersion('3.0.0'): from rest_framework.serializers import Serializer else: - class Serializer(rest_framework.serializers.Serializer): + class Serializer(serializers.Serializer): @property def object(self): return self.validated_data + + +def get_uuid_field(): + """ + Returns a partial object that when called instantiates a UUIDField + either from Django 1.8's native implementation, or as a CharField. + """ + if hasattr(models, 'UUIDField'): + return partial(models.UUIDField, editable=False, unique=True) + else: + return partial(models.CharField, max_length=64, + editable=False, unique=True) diff --git a/rest_framework_jwt/models.py b/rest_framework_jwt/models.py deleted file mode 100644 index 5b53a526..00000000 --- a/rest_framework_jwt/models.py +++ /dev/null @@ -1 +0,0 @@ -# Just to keep things like ./manage.py test happy diff --git a/rest_framework_jwt/serializers.py b/rest_framework_jwt/serializers.py index beb5ad4f..e8019f0d 100644 --- a/rest_framework_jwt/serializers.py +++ b/rest_framework_jwt/serializers.py @@ -3,19 +3,22 @@ from calendar import timegm from datetime import datetime, timedelta +from django.conf import settings from django.contrib.auth import authenticate from django.utils.translation import ugettext as _ -from rest_framework import serializers -from .compat import Serializer +from rest_framework import exceptions +from rest_framework import serializers from rest_framework_jwt import utils from rest_framework_jwt.settings import api_settings +from .compat import Serializer jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER jwt_decode_handler = api_settings.JWT_DECODE_HANDLER jwt_get_user_id_from_payload = api_settings.JWT_PAYLOAD_GET_USER_ID_HANDLER +jwt_blacklist_get_handler = api_settings.JWT_BLACKLIST_GET_HANDLER class JSONWebTokenSerializer(Serializer): @@ -103,6 +106,14 @@ def _check_payload(self, token): msg = _('Error decoding signature.') raise serializers.ValidationError(msg) + # Check if the token has been blacklisted. + if 'rest_framework_jwt.blacklist' in settings.INSTALLED_APPS: + blacklisted = jwt_blacklist_get_handler(payload) + + if blacklisted: + msg = _('Token is blacklisted.') + raise exceptions.AuthenticationFailed(msg) + return payload def _check_user(self, payload): @@ -150,6 +161,7 @@ def validate(self, attrs): payload = self._check_payload(token=token) user = self._check_user(payload=payload) + # Get and check 'orig_iat' orig_iat = payload.get('orig_iat') diff --git a/rest_framework_jwt/settings.py b/rest_framework_jwt/settings.py index 178b4f21..e13f7128 100644 --- a/rest_framework_jwt/settings.py +++ b/rest_framework_jwt/settings.py @@ -22,6 +22,15 @@ 'JWT_RESPONSE_PAYLOAD_HANDLER': 'rest_framework_jwt.utils.jwt_response_payload_handler', + 'JWT_BLACKLIST_GET_HANDLER': + 'rest_framework_jwt.blacklist.utils.jwt_blacklist_get_handler', + + 'JWT_BLACKLIST_SET_HANDLER': + 'rest_framework_jwt.blacklist.utils.jwt_blacklist_set_handler', + + 'JWT_BLACKLIST_RESPONSE_HANDLER': + 'rest_framework_jwt.blacklist.utils.jwt_blacklist_response_handler', + 'JWT_SECRET_KEY': settings.SECRET_KEY, 'JWT_ALGORITHM': 'HS256', 'JWT_VERIFY': True, @@ -30,10 +39,8 @@ 'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=300), 'JWT_AUDIENCE': None, 'JWT_ISSUER': None, - 'JWT_ALLOW_REFRESH': False, 'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=7), - 'JWT_AUTH_HEADER_PREFIX': 'JWT', } @@ -44,6 +51,9 @@ 'JWT_PAYLOAD_HANDLER', 'JWT_PAYLOAD_GET_USER_ID_HANDLER', 'JWT_RESPONSE_PAYLOAD_HANDLER', + 'JWT_BLACKLIST_GET_HANDLER', + 'JWT_BLACKLIST_SET_HANDLER', + 'JWT_BLACKLIST_RESPONSE_HANDLER', ) api_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS) diff --git a/rest_framework_jwt/utils.py b/rest_framework_jwt/utils.py index 6b18b2cc..3877a147 100644 --- a/rest_framework_jwt/utils.py +++ b/rest_framework_jwt/utils.py @@ -1,4 +1,5 @@ import jwt +import uuid from datetime import datetime @@ -26,7 +27,8 @@ def jwt_payload_handler(user): 'user_id': user.pk, 'email': user.email, 'username': username, - 'exp': datetime.utcnow() + api_settings.JWT_EXPIRATION_DELTA + 'exp': datetime.utcnow() + api_settings.JWT_EXPIRATION_DELTA, + 'jti': uuid.uuid4().hex } @@ -34,8 +36,7 @@ def jwt_get_user_id_from_payload_handler(payload): """ Override this function if user_id is formatted differently in payload """ - user_id = payload.get('user_id') - return user_id + return payload.get('user_id') def jwt_encode_handler(payload): @@ -78,6 +79,7 @@ def jwt_response_payload_handler(token, user=None, request=None): } """ + return { 'token': token } diff --git a/rest_framework_jwt/views.py b/rest_framework_jwt/views.py index e951dd51..ed8c09ee 100644 --- a/rest_framework_jwt/views.py +++ b/rest_framework_jwt/views.py @@ -6,10 +6,7 @@ from rest_framework_jwt.settings import api_settings -from .serializers import ( - JSONWebTokenSerializer, RefreshJSONWebTokenSerializer, - VerifyJSONWebTokenSerializer -) +from . import serializers jwt_response_payload_handler = api_settings.JWT_RESPONSE_PAYLOAD_HANDLER @@ -23,6 +20,7 @@ class JSONWebTokenAPIView(APIView): authentication_classes = () parser_classes = (parsers.FormParser, parsers.JSONParser,) renderer_classes = (renderers.JSONRenderer,) + response_payload_handler = staticmethod(jwt_response_payload_handler) def post(self, request): serializer = self.serializer_class(data=request.DATA) @@ -30,7 +28,7 @@ def post(self, request): if serializer.is_valid(): user = serializer.object.get('user') or request.user token = serializer.object.get('token') - response_data = jwt_response_payload_handler(token, user, request) + response_data = self.response_payload_handler(token, user, request) return Response(response_data) @@ -43,7 +41,7 @@ class ObtainJSONWebToken(JSONWebTokenAPIView): Returns a JSON Web Token that can be used for authenticated requests. """ - serializer_class = JSONWebTokenSerializer + serializer_class = serializers.JSONWebTokenSerializer class VerifyJSONWebToken(JSONWebTokenAPIView): @@ -51,7 +49,7 @@ class VerifyJSONWebToken(JSONWebTokenAPIView): API View that checks the veracity of a token, returning the token if it is valid. """ - serializer_class = VerifyJSONWebTokenSerializer + serializer_class = serializers.VerifyJSONWebTokenSerializer class RefreshJSONWebToken(JSONWebTokenAPIView): @@ -62,7 +60,7 @@ class RefreshJSONWebToken(JSONWebTokenAPIView): If 'orig_iat' field (original issued-at-time) is found, will first check if it's within expiration window, then copy it to the new token """ - serializer_class = RefreshJSONWebTokenSerializer + serializer_class = serializers.RefreshJSONWebTokenSerializer obtain_jwt_token = ObtainJSONWebToken.as_view() diff --git a/tests/conftest.py b/tests/conftest.py index fe079bc7..a052e3b6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,6 +36,8 @@ def pytest_configure(): 'django.contrib.staticfiles', 'tests', + 'rest_framework_jwt', + 'rest_framework_jwt.blacklist', ), PASSWORD_HASHERS=( 'django.contrib.auth.hashers.MD5PasswordHasher', diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 3cad6f95..5cda0609 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -3,8 +3,10 @@ from django.utils import unittest from django.conf.urls import patterns from django.contrib.auth import get_user_model +from django.utils.timezone import now from rest_framework import permissions, status + try: from rest_framework_oauth.authentication import OAuth2Authentication except ImportError: @@ -35,6 +37,7 @@ from rest_framework_jwt import utils from rest_framework_jwt.settings import api_settings, DEFAULTS from rest_framework_jwt.authentication import JSONWebTokenAuthentication +from rest_framework_jwt.blacklist.models import JWTBlacklistToken User = get_user_model() @@ -78,6 +81,43 @@ def setUp(self): self.email = 'jpueblo@example.com' self.user = User.objects.create_user(self.username, self.email) + def test_post_json_passing_jwt_auth_blacklist_enabled(self): + """ + Ensure POSTing JSON over JWT auth with correct credentials + passes and does not require CSRF + """ + payload = utils.jwt_payload_handler(self.user) + token = utils.jwt_encode_handler(payload) + + auth = 'JWT {0}'.format(token) + response = self.csrf_client.post( + '/jwt/', {'example': 'example'}, + HTTP_AUTHORIZATION=auth, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_post_blacklisted_token_failing_jwt_auth(self): + """ + Ensure POSTing over JWT auth with blacklisted token fails + """ + payload = utils.jwt_payload_handler(self.user) + token = utils.jwt_encode_handler(payload) + + # Create blacklist token which effectively blacklists the token. + JWTBlacklistToken.objects.create(jti=payload.get('jti'), + created=now(), expires=now()) + + auth = 'JWT {0}'.format(token) + response = self.csrf_client.post( + '/jwt/', {'example': 'example'}, + HTTP_AUTHORIZATION=auth, format='json') + + msg = 'Token is blacklisted.' + + self.assertEqual(response.data['detail'], msg) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(response['WWW-Authenticate'], 'JWT realm="api"') + def test_post_form_passing_jwt_auth(self): """ Ensure POSTing form over JWT auth with correct credentials diff --git a/tests/test_serializers.py b/tests/test_serializers.py index a7b4fd3b..1b673102 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model from rest_framework_jwt.serializers import JSONWebTokenSerializer +from rest_framework_jwt.blacklist.serializers import BlacklistJSONWebTokenSerializer from rest_framework_jwt import utils User = get_user_model() @@ -93,3 +94,42 @@ def test_required_fields(self): self.assertFalse(is_valid) self.assertEqual(serializer.errors, expected_error) + + +class BlacklistJSONWebTokenSerializerTests(TestCase): + + def setUp(self): + self.email = 'jpueblo@example.com' + self.username = 'jpueblo' + self.password = 'password' + self.user = User.objects.create_user( + self.username, self.email, self.password) + + self.payload = utils.jwt_payload_handler(self.user) + self.data = { + 'token': utils.jwt_encode_handler(self.payload) + } + + def test_token_blacklisted(self): + serializer = BlacklistJSONWebTokenSerializer(data=self.data) + is_valid = serializer.is_valid() + + token = serializer.object['token'] + + self.assertTrue(is_valid) + self.assertEqual(self.payload.get('jti'), token.jti) + + def test_token_blacklist_fail_missing_jti(self): + self.payload['jti'] = None + self.data = { + 'token': utils.jwt_encode_handler(self.payload) + } + + serializer = BlacklistJSONWebTokenSerializer(data=self.data) + is_valid = serializer.is_valid() + + self.assertFalse(is_valid) + + msg = 'Could not blacklist token.' + + self.assertEqual(serializer.errors['non_field_errors'][0], msg) diff --git a/tests/test_utils.py b/tests/test_utils.py index 4927948a..7db44524 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,11 +1,17 @@ import json +import time import base64 +import jwt.exceptions +from django.db import models from django.contrib.auth import get_user_model +from django.utils.timezone import now from django.test import TestCase -import jwt.exceptions + from rest_framework_jwt import utils from rest_framework_jwt.settings import api_settings, DEFAULTS +from rest_framework_jwt.blacklist import utils as blacklist_utils +from rest_framework_jwt.blacklist.models import JWTBlacklistToken User = get_user_model() @@ -57,6 +63,52 @@ def test_jwt_response_payload(self): self.assertEqual(response_data, dict(token=token)) + def test_jwt_blacklist_get_success(self): + payload = utils.jwt_payload_handler(self.user) + + # Create blacklisted token. + token_created = JWTBlacklistToken.objects.create( + jti=payload.get('jti'), + expires=now(), + created=now() + ) + + token_fetched = blacklist_utils.jwt_blacklist_get_handler(payload) + + if hasattr(models, 'UUIDField'): + self.assertEqual(token_created.jti, token_fetched.jti.hex) + else: + self.assertEqual(token_created.jti, token_fetched.jti) + + def test_jwt_blacklist_get_fail(self): + payload = utils.jwt_payload_handler(self.user) + + # Test that incoming empty jti fails. + payload['jti'] = None + + token_fetched = blacklist_utils.jwt_blacklist_get_handler(payload) + + self.assertIsNone(token_fetched) + + def test_jwt_blacklist_set_success(self): + payload = utils.jwt_payload_handler(self.user) + + # exp field comes in as seconds since epoch + payload['exp'] = int(time.time()) + + # Create blacklisted token. + token = blacklist_utils.jwt_blacklist_set_handler(payload) + + self.assertEqual(token.jti, payload.get('jti')) + + def test_jwt_blacklist_set_fail(self): + payload = utils.jwt_payload_handler(self.user) + + # Create blacklisted token. + token = blacklist_utils.jwt_blacklist_set_handler(payload) + + self.assertIsNone(token) + def test_jwt_decode_verify_exp(self): api_settings.JWT_VERIFY_EXPIRATION = False diff --git a/tests/test_views.py b/tests/test_views.py index 820280bf..d467734f 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -27,7 +27,8 @@ (r'^auth-token/$', 'rest_framework_jwt.views.obtain_jwt_token'), (r'^auth-token-refresh/$', 'rest_framework_jwt.views.refresh_jwt_token'), (r'^auth-token-verify/$', 'rest_framework_jwt.views.verify_jwt_token'), - + (r'^auth-token-blacklist/$', + 'rest_framework_jwt.blacklist.views.blacklist_jwt_token'), ) orig_datetime = datetime @@ -226,6 +227,7 @@ def create_token(self, user, exp=None, orig_iat=None): payload['orig_iat'] = timegm(orig_iat.utctimetuple()) token = utils.jwt_encode_handler(payload) + return token @@ -234,7 +236,7 @@ class VerifyJSONWebTokenTests(TokenTestCase): def test_verify_jwt(self): """ Test that a valid, non-expired token will return a 200 response - and itself when passed to the validation endpoint. + when passed to the validation endpoint. """ client = APIClient(enforce_csrf_checks=True) @@ -247,8 +249,6 @@ def test_verify_jwt(self): format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['token'], orig_token) - def test_verify_jwt_fails_with_expired_token(self): """ Test that an expired token will fail with the correct error. @@ -301,6 +301,32 @@ def test_verify_jwt_fails_with_missing_user(self): self.assertRegexpMatches(response.data['non_field_errors'][0], "User doesn't exist") + def test_verify_jwt_fails_with_blacklisted_token(self): + """ + Test that a blacklisted token will fail. + """ + client = APIClient(enforce_csrf_checks=True) + + user = User.objects.create_user( + email='jsmith@example.com', username='jsmith', password='password') + + token = self.create_token(user) + + # Handle blacklisting the token. + response = client.post('/auth-token-blacklist/', {'token': token}, + format='json') + + msg = 'Token successfully blacklisted.' + + self.assertEqual(response.data['message'], msg) + + response = client.post('/auth-token-verify/', {'token': token}, + format='json') + + msg = 'Token is blacklisted.' + + self.assertEqual(response.data['detail'], msg) + class RefreshJSONWebTokenTests(TokenTestCase): @@ -344,8 +370,11 @@ def test_refresh_jwt_after_refresh_expiration(self): """ client = APIClient(enforce_csrf_checks=True) - orig_iat = (datetime.utcnow() - api_settings.JWT_REFRESH_EXPIRATION_DELTA - - timedelta(seconds=5)) + orig_iat = ( + datetime.utcnow() - + api_settings.JWT_REFRESH_EXPIRATION_DELTA - + timedelta(seconds=5) + ) token = self.create_token( self.user, exp=datetime.utcnow() + timedelta(hours=1), @@ -361,3 +390,22 @@ def test_refresh_jwt_after_refresh_expiration(self): def tearDown(self): # Restore original settings api_settings.JWT_ALLOW_REFRESH = DEFAULTS['JWT_ALLOW_REFRESH'] + + +class BlacklistJSONWebTokenTests(TokenTestCase): + + def test_blacklist_jwt_successful_blacklist_enabled(self): + client = APIClient(enforce_csrf_checks=True) + + user = User.objects.create_user( + email='jsmith@example.com', username='jsmith', password='password') + + token = self.create_token(user) + + # Handle blacklisting the token. + response = client.post('/auth-token-blacklist/', {'token': token}, + format='json') + + msg = 'Token successfully blacklisted.' + + self.assertEqual(response.data['message'], msg)