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

Long running refresh tokens #123

Closed
wants to merge 5 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
53 changes: 53 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,59 @@ 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.

## Long Running Refresh Token

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.

Declare the app
```python
INSTALLED_APPS = [
...,
'rest_framework_jwt.refreshtoken',
]

```

Run migrations

```bash
$ python manage.py migrate refreshtoken
```

Configure your urls to add new endpoint

```python
from rest_framework_jwt.refreshtoken.routers import urlpatterns as jwt_urlpatterns

urlpatterns = [
url(...),
] + jwt_urlpatterns

```

You can include this refresh token in your JWT_RESPONSE_PAYLOAD_HANDLER

```python

def jwt_response_payload_handler(token, user=None, request=None):
return {
'token': token,
'user': UserSerializer(user).data,
'refresh_token': user.refresh_tokens.first().key,
}

```

Then your user can ask a new JWT token as long as the refresh_token exists.

```bash
$ curl -X POST -d '{"client_id": "app", "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "refresh_token": <REFRESH_TOKEN>, "api_type": "app"}' http://localhost:8000/delegate/
'{"token": "your_jwt_token_..."}'

```

## 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.
Expand Down
10 changes: 10 additions & 0 deletions rest_framework_jwt/compat.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import rest_framework
import rest_framework.exceptions
from distutils.version import StrictVersion


Expand All @@ -9,3 +10,12 @@ class Serializer(rest_framework.serializers.Serializer):
@property
def object(self):
return self.validated_data

try:
from rest_framework.serializers import CurrentUserDefault
except ImportError:
# DRF 2.4
class CurrentUserDefault(object):

def __call__(self):
pass
Empty file.
24 changes: 24 additions & 0 deletions rest_framework_jwt/refreshtoken/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.db import models, migrations
from django.conf import settings


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='RefreshToken',
fields=[
('key', models.CharField(max_length=40, primary_key=True, serialize=False)),
('app', models.CharField(unique=True, max_length=255)),
('created', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(related_name='refresh_tokens', to=settings.AUTH_USER_MODEL)),
],
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would probably be best to squash these migrations?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but I would like to do it once @jpadilla considers it ready for merging. Just in case some amendments are still necessary on the Model.
I will add it in the description as a reminder.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


from django.db import models, migrations


class Migration(migrations.Migration):

dependencies = [
('refreshtoken', '0001_initial'),
]

operations = [
migrations.AlterField(
model_name='refreshtoken',
name='app',
field=models.CharField(max_length=255),
),
migrations.AlterUniqueTogether(
name='refreshtoken',
unique_together=set([('user', 'app')]),
),
]
Empty file.
43 changes: 43 additions & 0 deletions rest_framework_jwt/refreshtoken/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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)
created = models.DateTimeField(auto_now_add=True)

class Meta:
unique_together = ('user', 'app')

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'),
)
55 changes: 55 additions & 0 deletions rest_framework_jwt/refreshtoken/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from django.utils.translation import ugettext as _
from rest_framework import exceptions
from rest_framework import serializers
from rest_framework_jwt.compat import CurrentUserDefault, Serializer

from .models import RefreshToken


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

user = serializers.PrimaryKeyRelatedField(
required=False,
read_only=True,
default=CurrentUserDefault())

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

def validate(self, attrs):
"""
only for DRF < 3.0 support.
Otherwise CurrentUserDefault() is doing the job of obtaining user
from current request.
"""
if 'user' not in attrs:
attrs['user'] = self.context['request'].user
return attrs


class DelegateJSONWebTokenSerializer(Serializer):
client_id = serializers.CharField()
grant_type = serializers.CharField(
default='urn:ietf:params:oauth:grant-type:jwt-bearer',
required=False,
)
refresh_token = serializers.CharField()
api_type = serializers.CharField(
default='app',
required=False,
)

def validate(self, attrs):
refresh_token = attrs['refresh_token']
try:
token = RefreshToken.objects.select_related('user').get(
key=refresh_token)
except RefreshToken.DoesNotExist:
raise exceptions.AuthenticationFailed(_('Invalid token.'))
attrs['user'] = token.user
return attrs
74 changes: 74 additions & 0 deletions rest_framework_jwt/refreshtoken/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from calendar import timegm
from datetime import datetime

from django.utils.translation import ugettext as _
from rest_framework import exceptions
from rest_framework import generics
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 .permissions import IsOwnerOrAdmin
from .models import RefreshToken
from .serializers import (
DelegateJSONWebTokenSerializer,
RefreshTokenSerializer,
)

jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER


class DelegateJSONWebToken(generics.CreateAPIView):
"""
API View that checks the veracity of a refresh token, returning a JWT if it
is valid.
"""
serializer_class = DelegateJSONWebTokenSerializer

def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.DATA)
# pass raise_exception=True argument once we drop support
# of DRF < 3.0
serializer.is_valid()
if serializer.errors:
return Response(serializer.errors,
status=status.HTTP_400_BAD_REQUEST)
user = serializer.object['user']
if not user.is_active:
raise exceptions.AuthenticationFailed(
_('User inactive or deleted.'))

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 @@ -63,7 +62,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
Loading