diff --git a/docs/index.md b/docs/index.md index b5300ec4..eb7ef94c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 -H "Authorization: RefreshToken " 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. diff --git a/rest_framework_jwt/compat.py b/rest_framework_jwt/compat.py index 01313aae..e0f4c79a 100644 --- a/rest_framework_jwt/compat.py +++ b/rest_framework_jwt/compat.py @@ -9,3 +9,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 diff --git a/rest_framework_jwt/refreshtoken/__init__.py b/rest_framework_jwt/refreshtoken/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rest_framework_jwt/refreshtoken/authentication.py b/rest_framework_jwt/refreshtoken/authentication.py new file mode 100644 index 00000000..e69de29b diff --git a/rest_framework_jwt/refreshtoken/migrations/0001_initial.py b/rest_framework_jwt/refreshtoken/migrations/0001_initial.py new file mode 100644 index 00000000..6f849b3e --- /dev/null +++ b/rest_framework_jwt/refreshtoken/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/rest_framework_jwt/refreshtoken/migrations/0002_auto_20150515_0948.py b/rest_framework_jwt/refreshtoken/migrations/0002_auto_20150515_0948.py new file mode 100644 index 00000000..6a39032c --- /dev/null +++ b/rest_framework_jwt/refreshtoken/migrations/0002_auto_20150515_0948.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +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')]), + ), + ] diff --git a/rest_framework_jwt/refreshtoken/migrations/__init__.py b/rest_framework_jwt/refreshtoken/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rest_framework_jwt/refreshtoken/models.py b/rest_framework_jwt/refreshtoken/models.py new file mode 100644 index 00000000..e2303d82 --- /dev/null +++ b/rest_framework_jwt/refreshtoken/models.py @@ -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 diff --git a/rest_framework_jwt/refreshtoken/permissions.py b/rest_framework_jwt/refreshtoken/permissions.py new file mode 100644 index 00000000..1c08f80e --- /dev/null +++ b/rest_framework_jwt/refreshtoken/permissions.py @@ -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 diff --git a/rest_framework_jwt/refreshtoken/routers.py b/rest_framework_jwt/refreshtoken/routers.py new file mode 100644 index 00000000..2318904a --- /dev/null +++ b/rest_framework_jwt/refreshtoken/routers.py @@ -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'), +) diff --git a/rest_framework_jwt/refreshtoken/serializers.py b/rest_framework_jwt/refreshtoken/serializers.py new file mode 100644 index 00000000..e94170e5 --- /dev/null +++ b/rest_framework_jwt/refreshtoken/serializers.py @@ -0,0 +1,43 @@ +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, + ) diff --git a/rest_framework_jwt/refreshtoken/views.py b/rest_framework_jwt/refreshtoken/views.py new file mode 100644 index 00000000..9f66610e --- /dev/null +++ b/rest_framework_jwt/refreshtoken/views.py @@ -0,0 +1,75 @@ +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) + serializer.is_valid(raise_exception=True) + refresh_token = serializer.object['refresh_token'] + try: + token = RefreshToken.objects.select_related('user').get( + key=refresh_token) + except RefreshToken.DoesNotExist: + raise exceptions.AuthenticationFailed(_('Invalid token.')) + user = token.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) diff --git a/rest_framework_jwt/views.py b/rest_framework_jwt/views.py index 650ef1cd..595696a7 100644 --- a/rest_framework_jwt/views.py +++ b/rest_framework_jwt/views.py @@ -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 ( @@ -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) diff --git a/runtests.py b/runtests.py index 45dcbf4e..627b1613 100755 --- a/runtests.py +++ b/runtests.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py index fe079bc7..eb7ee83d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ def pytest_configure(): 'NAME': ':memory:' } }, + SOUTH_TESTS_MIGRATE=False, SITE_ID=1, SECRET_KEY='not very secret in tests', USE_I18N=True, @@ -36,10 +37,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: diff --git a/tests/test_views.py b/tests/test_views.py index 820280bf..4dae33ae 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -3,6 +3,7 @@ from django import get_version from django.test import TestCase +from django.core.urlresolvers import reverse from django.test.utils import override_settings from django.utils import unittest from django.conf.urls import patterns @@ -11,10 +12,11 @@ from freezegun import freeze_time from rest_framework import status -from rest_framework.test import APIClient +from rest_framework.test import APIClient, APITestCase from rest_framework_jwt import utils from rest_framework_jwt.settings import api_settings, DEFAULTS +from rest_framework_jwt.refreshtoken.models import RefreshToken from . import utils as test_utils @@ -50,10 +52,9 @@ def setUp(self): class TestCustomResponsePayload(BaseTestCase): - def setUp(self): - api_settings.JWT_RESPONSE_PAYLOAD_HANDLER = test_utils\ - .jwt_response_payload_handler + api_settings.JWT_RESPONSE_PAYLOAD_HANDLER = test_utils.\ + jwt_response_payload_handler return super(TestCustomResponsePayload, self).setUp() def test_jwt_login_custom_response_json(self): @@ -361,3 +362,135 @@ def test_refresh_jwt_after_refresh_expiration(self): def tearDown(self): # Restore original settings api_settings.JWT_ALLOW_REFRESH = DEFAULTS['JWT_ALLOW_REFRESH'] + + +class RefreshTokenTestCase(APITestCase): + urls = 'rest_framework_jwt.refreshtoken.routers' + + 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.token = RefreshToken.objects.create(user=self.user, app='test-app') + email1 = 'jonny@example.com' + username1 = 'jonnytestpants' + password1 = 'password' + self.user1 = User.objects.create_user(username1, email1, password1) + self.token1 = RefreshToken.objects.create(user=self.user1, app='another-app') + + self.list_url = reverse('refreshtoken-list') + self.detail_url = reverse( + 'refreshtoken-detail', + kwargs={'key': self.token.key} + ) + self.detail_url1 = reverse( + 'refreshtoken-detail', + kwargs={'key': self.token1.key} + ) + self.delegate_url = reverse('delegate-tokens') + + def test_requires_auth(self): + response = self.client.get(self.list_url) + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + (response.status_code, response.content) + ) + + response = self.client.get(self.detail_url) + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + (response.status_code, response.content) + ) + + response = self.client.delete(self.detail_url) + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + (response.status_code, response.content) + ) + + response = self.client.post(self.list_url) + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + (response.status_code, response.content) + ) + + def test_get_refresh_token_list(self): + self.client.force_authenticate(self.user) + response = self.client.get(self.list_url) + self.assertEqual(len(response.data), 1) + resp0 = response.data[0] + self.assertEqual(self.token.key, resp0['key']) + + self.client.force_authenticate(self.user1) + response = self.client.get(self.list_url) + self.assertEqual(len(response.data), 1) + resp0 = response.data[0] + self.assertEqual(self.token1.key, resp0['key']) + + self.assertEqual(RefreshToken.objects.count(), 2) + + def test_get_refresth_token_detail(self): + self.client.force_authenticate(self.user) + response = self.client.get(self.detail_url) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + (response.status_code, response.content) + ) + response = self.client.get(self.detail_url1) + self.assertEqual( + response.status_code, + status.HTTP_404_NOT_FOUND, + (response.status_code, response.content) + ) + + def test_delete_refresth_token(self): + self.client.force_authenticate(self.user) + response = self.client.delete(self.detail_url) + self.assertEqual( + response.status_code, + status.HTTP_204_NO_CONTENT, + (response.status_code, response.content) + ) + response = self.client.delete(self.detail_url1) + self.assertEqual( + response.status_code, + status.HTTP_404_NOT_FOUND, + (response.status_code, response.content) + ) + + def test_create_refresth_token(self): + self.client.force_authenticate(self.user) + data = { + 'app': 'gandolf' + } + response = self.client.post(self.list_url, data, format='json') + self.assertEqual( + response.status_code, + status.HTTP_201_CREATED, + (response.status_code, response.content) + ) + self.assertEqual(response.data['user'], self.user.pk) + self.assertEqual(response.data['app'], data['app']) + + def test_delegate_jwt(self): + data = { + 'client_id': 'gandolf', + 'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'refresh_token': self.token1.key, + 'api_type': 'app', + } + response = self.client.post(self.delegate_url, data=data, + format='json') + self.assertEqual( + response.status_code, + status.HTTP_201_CREATED, + (response.status_code, response.content) + ) + self.assertIn('token', response.data) diff --git a/tox.ini b/tox.ini index be7d0f1e..7ae5f213 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ deps = drf2.4.3: djangorestframework==2.4.3 drf2.4.4: djangorestframework==2.4.4 drf3.0: djangorestframework==3.0.5 - drf3.1: djangorestframework==3.1.1 + drf3.1: djangorestframework==3.1.2 py27-django1.6-drf{2.4.3,2.4.4,3.0,3.1}: oauth2==1.5.211 py27-django1.6-drf{2.4.3,2.4.4,3.0,3.1}: django-oauth-plus==2.2.6 py27-django1.6-drf{2.4.3,2.4.4,3.0,3.1}: django-oauth2-provider==0.2.6.1