-
Notifications
You must be signed in to change notification settings - Fork 652
Adds blacklist token feature as separate subapp #91
Changes from all commits
b175f39
003e805
8c23e9e
dd86025
0c0f946
caaa6eb
e08ad93
09d5b8a
4bc7e96
1eaf24f
dda8303
c592c25
8a52647
9fc7750
2b80040
d63cb70
303dd59
fda33b3
0452dbe
e857b80
35f7017
75e4f4d
849f879
84b4baa
8e29a5c
48b26e6
5a8496d
b5e5f49
20799c3
568efb9
16f16df
e664e1c
8b42c64
b553d93
aba07a0
4272c04
7df1149
d0d1626
6ecdf7f
e7c3d22
10ab7e0
00dbd12
ef176c0
30b9531
4bff8e3
41e9955
8bd31fe
8ac3f8b
ad15329
5fa6077
f47aec1
7c037c7
83451de
14debb4
15c9b13
319c6a2
05b0839
b097594
2e118c0
84c72ea
c44bd87
906896d
391928f
f56e955
4b56adc
6425a2e
3d4c00b
c7c04c5
6294afa
617c436
255a91a
285cfb2
df83277
e6c3843
8efe5ca
4312e87
27667e1
7eb1019
335bc62
c399a3a
14085e5
ec73b3e
8ce2fe2
29bdfa6
cc3c32a
62efabc
869e902
5ca120d
a12f8e7
73d3d1a
f0f6b57
baa5cac
ce72d61
fc99489
e9760dc
d65459e
ae33ba4
975811a
c03f6f1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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":"<EXISTING_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":"<EXISTING_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', | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same about all these utils... should they belong in the blacklist app as well? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hm they could, although it might be easier to keep them grouped with the other utils(?) would like @jpadilla input on this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it does make sense to separate the views and utils into the subapp as well so its completely decoupled There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea I guess it makes sense for these to be separate as well. |
||
'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. | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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.') | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not really excited about introducing an optional requirement here just for a UUIDField. I think we can just simplify this for built-in Django 1.8 UUIDField and CharField for anything less. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sounds good.. less dependency |
||
""" | ||
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) |
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would add something about significant overhead to query the auth service on every request to check if the JWT is blacklisted. (Which defeats the purpose of using JWT's anyway)