Skip to content
This repository has been archived by the owner on May 26, 2020. It is now read-only.

Commit

Permalink
Long running refresh tokens
Browse files Browse the repository at this point in the history
This allows for a client to request refresh tokens. These refresh tokens do not expire.
They can be revoked (deleted). When a JWT has expired, it's possible to send a request
with the refresh token in the header, and get back a new JWT. This allows for the client
to not have to store username/passwords. So, if the client gets a responce about an expired token
the client can automatically make a call (behind the scenes) to delegate a new JWT using
the stored refresh token. Thus keeping the 'session' active.

moving everything to it's own sub dir, so that the refresh token functionality can be optionally installed.
  • Loading branch information
fxdgear committed Apr 2, 2015
1 parent 96eb5d9 commit 77fcc13
Show file tree
Hide file tree
Showing 11 changed files with 336 additions and 9 deletions.
46 changes: 43 additions & 3 deletions rest_framework_jwt/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
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)
from rest_framework.authentication import (
BaseAuthentication, get_authorization_header, TokenAuthentication
)

from rest_framework_jwt import utils
from rest_framework_jwt.settings import api_settings

from rest_framework_jwt.refreshtoken.models import RefreshToken

jwt_decode_handler = api_settings.JWT_DECODE_HANDLER
jwt_get_user_id_from_payload = api_settings.JWT_PAYLOAD_GET_USER_ID_HANDLER
Expand Down Expand Up @@ -95,3 +96,42 @@ def authenticate_header(self, request):
authentication scheme should return `403 Permission Denied` responses.
"""
return 'JWT realm="{0}"'.format(self.www_authenticate_realm)


class RefreshTokenAuthentication(TokenAuthentication):
"""
Subclassed from rest_framework.authentication.TokenAuthentication
Auth header:
Authorization: RefreshToken 401f7ac837da42b97f613d789819ff93537bee6a
"""
model = RefreshToken

def authenticate(self, request):
auth = get_authorization_header(request).split()

if not auth or auth[0].lower() != b'refreshtoken':
return None

if len(auth) == 1:
msg = _('Invalid token header. No credentials provided.')
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _('Invalid token header. Token string should not contain spaces.')
raise exceptions.AuthenticationFailed(msg)

return self.authenticate_credentials(auth[1])

def authenticate_credentials(self, key):
try:
token = self.model.objects.select_related('user').get(key=key)
except self.model.DoesNotExist:
raise exceptions.AuthenticationFailed(_('Invalid token.'))

if not token.user.is_active:
raise exceptions.AuthenticationFailed(_('User inactive or deleted.'))

return (token.user, token)

def authenticate_header(self, request):
return 'RefreshToken'
Empty file.
40 changes: 40 additions & 0 deletions rest_framework_jwt/refreshtoken/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import binascii
import os

from django.conf import settings
from django.db import models
from django.utils.encoding import python_2_unicode_compatible


# Prior to Django 1.5, the AUTH_USER_MODEL setting does not exist.
# Note that we don't perform this code in the compat module due to
# bug report #1297
# See: https://github.com/tomchristie/django-rest-framework/issues/1297
AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User')


@python_2_unicode_compatible
class RefreshToken(models.Model):
"""
Copied from
https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/authtoken/models.py
Wanted to only change the user relation to be a "ForeignKey" instead of a OneToOneField
The `ForeignKey` value allows us to create multiple RefreshTokens per user
"""
key = models.CharField(max_length=40, primary_key=True)
user = models.ForeignKey(AUTH_USER_MODEL, related_name='refresh_tokens')
app = models.CharField(max_length=255, unique=True)
created = models.DateTimeField(auto_now_add=True)

def save(self, *args, **kwargs):
if not self.key:
self.key = self.generate_key()
return super(RefreshToken, self).save(*args, **kwargs)

def generate_key(self):
return binascii.hexlify(os.urandom(20)).decode()

def __str__(self):
return self.key
21 changes: 21 additions & 0 deletions rest_framework_jwt/refreshtoken/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from rest_framework import permissions


class IsOwnerOrAdmin(permissions.BasePermission):
"""
Only admins or owners can have permission
"""
def has_permission(self, request, view):
return request.user and request.user.is_authenticated()

def has_object_permission(self, request, view, obj):
"""
If user is staff or superuser or 'owner' of object return True
Else return false.
"""
if not request.user.is_authenticated():
return False
elif request.user.is_staff or request.user.is_superuser:
return True
else:
return request.user == obj.user
11 changes: 11 additions & 0 deletions rest_framework_jwt/refreshtoken/routers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from rest_framework import routers
from django.conf.urls import patterns, url

from .views import RefreshTokenViewSet, DelagateJSONWebToken

router = routers.SimpleRouter()
router.register(r'refresh-token', RefreshTokenViewSet)

urlpatterns = router.urls + patterns('', # NOQA
url(r'delgate/$', DelagateJSONWebToken.as_view(), name='delgate-tokens'),
)
17 changes: 17 additions & 0 deletions rest_framework_jwt/refreshtoken/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from .models import RefreshToken
from rest_framework import serializers


class RefreshTokenSerializer(serializers.ModelSerializer):
"""
Serializer for refresh tokens (Not RefreshJWTToken)
"""

class Meta:
model = RefreshToken
fields = ('key', 'user', 'created', 'app')
read_only_fields = ('key', 'user', 'created')

def validate(self, attrs):
attrs['user'] = self.context['request'].user
return attrs
65 changes: 65 additions & 0 deletions rest_framework_jwt/refreshtoken/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from calendar import timegm
from datetime import datetime

from rest_framework import mixins
from rest_framework import viewsets
from rest_framework.response import Response
from rest_framework import status
from rest_framework import parsers
from rest_framework import renderers

from rest_framework_jwt.settings import api_settings
from rest_framework_jwt.views import JSONWebTokenAPIView
from rest_framework_jwt.authentication import RefreshTokenAuthentication

from .permissions import IsOwnerOrAdmin
from .models import RefreshToken
from .serializers import RefreshTokenSerializer

jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER


class DelagateJSONWebToken(JSONWebTokenAPIView):
"""
API View that checks the veracity of a refresh token, returning a JWT if it
is valid.
"""
authentication_classes = (RefreshTokenAuthentication, )

def post(self, request):
user = request.user
payload = jwt_payload_handler(user)
if api_settings.JWT_ALLOW_REFRESH:
payload['orig_iat'] = timegm(datetime.utcnow().utctimetuple())
return Response(
{'token': jwt_encode_handler(payload)},
status=status.HTTP_201_CREATED
)


class RefreshTokenViewSet(mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet):
"""
API View that will Create/Delete/List `RefreshToken`.
https://auth0.com/docs/refresh-token
"""
throttle_classes = ()
authentication_classes = ()
parser_classes = (parsers.FormParser, parsers.JSONParser,)
renderer_classes = (renderers.JSONRenderer,)
permission_classes = (IsOwnerOrAdmin, )
serializer_class = RefreshTokenSerializer
queryset = RefreshToken.objects.all()
lookup_field = 'key'

def get_queryset(self):
queryset = super(RefreshTokenViewSet, self).get_queryset()
if self.request.user.is_superuser or self.request.user.is_staff:
return queryset
else:
return queryset.filter(user=self.request.user)
2 changes: 0 additions & 2 deletions rest_framework_jwt/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from rest_framework import parsers
from rest_framework import renderers
from rest_framework.response import Response

from rest_framework_jwt.settings import api_settings

from .serializers import (
Expand Down Expand Up @@ -31,7 +30,6 @@ def post(self, request):
user = serializer.object.get('user') or request.user
token = serializer.object.get('token')
response_data = jwt_response_payload_handler(token, user, request)

return Response(response_data)

return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
Expand Down
5 changes: 5 additions & 0 deletions runtests.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,29 @@

sys.path.append(os.path.dirname(__file__))


def exit_on_failure(ret, message=None):
if ret:
sys.exit(ret)


def flake8_main(args):
print('Running flake8 code linting')
ret = subprocess.call(['flake8'] + args)
print('flake8 failed' if ret else 'flake8 passed')
return ret


def split_class_and_function(string):
class_string, function_string = string.split('.', 1)
return "%s and %s" % (class_string, function_string)


def is_function(string):
# `True` if it looks like a test function is included in the string.
return string.startswith('test_') or '.test_' in string


def is_class(string):
# `True` if first character is uppercase - assume it's a class name.
return string[0] == string[0].upper()
Expand Down
3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ def pytest_configure():
'NAME': ':memory:'
}
},
SOUTH_TESTS_MIGRATE=False,
SITE_ID=1,
SECRET_KEY='not very secret in tests',
USE_I18N=True,
Expand All @@ -35,10 +36,12 @@ def pytest_configure():
'django.contrib.staticfiles',

'tests',
'rest_framework_jwt.refreshtoken',
),
PASSWORD_HASHERS=(
'django.contrib.auth.hashers.MD5PasswordHasher',
),
SOUTH_DATABASE_ADAPTERS={'default': 'south.db.sqlite3'}
)

try:
Expand Down
Loading

0 comments on commit 77fcc13

Please sign in to comment.