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

Long running refresh tokens #94

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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, DelegateJSONWebToken

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

urlpatterns = router.urls + patterns('', # NOQA
url(r'^delegate/$', DelegateJSONWebToken.as_view(), name='delegate-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
59 changes: 59 additions & 0 deletions rest_framework_jwt/refreshtoken/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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_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 DelegateJSONWebToken(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
"""
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