Skip to content

Commit

Permalink
APDS-64 - [BE] add API to logout device (#6)
Browse files Browse the repository at this point in the history
* APDS-64 - [BE] remove Device on logout

* Corrects

* Replace doublequotes with singlequotes

* Remove duplicated lines

* Remove python setting from Travis config

* Use user agent in logout view

* Fix tests

* Remove Python 3.3 from travis

* CR

* CR

* APDS-63, APDS-62 - [BE] add API to refresh token using permanent_token (#4)

* APDS-63 - [BE] disallow passing permanent_token header in views other than device refresh

This requires APDS-62 to be finished

* APDS-62 - [BE] add API to refresh token using permanent_token

* Replace doublequotes with singlequotes

* Update with changes from APDS-62

* Remove python setting from .travis.yml

* Correct tests

* Auto logout when permanent token has expired

* Remove Python 3.3 from travis

* CR

* Remove addons from .travis.yml
  • Loading branch information
poxip authored Aug 1, 2017
1 parent 26b6e4e commit f92fea6
Show file tree
Hide file tree
Showing 7 changed files with 233 additions and 12 deletions.
7 changes: 0 additions & 7 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,6 @@ env:
matrix:
fast_finish: true

addons:
apt:
sources:
- deadsnakes
packages:
- python3.3

install:
- pip install tox

Expand Down
1 change: 1 addition & 0 deletions requirements/testing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
pytest==2.6.4
pytest-django==2.8.0
pytest-cov==1.6
freezegun==0.3.9

# Mocking the datetime module.
cryptography==1.2.3
28 changes: 27 additions & 1 deletion rest_framework_jwt/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def validate(self, attrs):
if api_settings.JWT_PERMANENT_TOKEN_AUTH:
headers = self.context['request'].META
device_name = headers.get('HTTP_X_DEVICE_MODEL')
user_agent = headers.get('HTTP_USER_AGENT')
user_agent = headers.get('HTTP_USER_AGENT', '')
if not device_name:
device_name = user_agent
device_details = ''
Expand Down Expand Up @@ -193,3 +193,29 @@ class DeviceSerializer(serializers.ModelSerializer):
class Meta:
model = Device
fields = ['id', 'created', 'name', 'details', 'last_request_datetime']


class DeviceTokenRefreshSerializer(Serializer):
permanent_token = serializers.CharField(required=True)

def validate(self, attrs):
permanent_token = attrs['permanent_token']
try:
device = Device.objects.get(permanent_token=permanent_token)
except Device.DoesNotExist:
raise serializers.ValidationError({'permanent_token': _('Invalid permanent_token value.')})

now = datetime.now()
if now > device.last_request_datetime + api_settings.JWT_PERMANENT_TOKEN_EXPIRATION_DELTA:
device.delete()
raise serializers.ValidationError({'permanent_token': _('Permanent token has expired.')})

if now > device.last_request_datetime + api_settings.JWT_PERMANENT_TOKEN_EXPIRATION_ACCURACY:
device.last_request_datetime = now
device.save()

payload = jwt_payload_handler(device.user, device=device)
return {
'token': jwt_encode_handler(payload),
'user': device.user
}
4 changes: 3 additions & 1 deletion rest_framework_jwt/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@
'JWT_AUTH_HEADER_PREFIX': 'JWT',
'JWT_AUTH_COOKIE': None,

'JWT_PERMANENT_TOKEN_AUTH': False
'JWT_PERMANENT_TOKEN_AUTH': False,
'JWT_PERMANENT_TOKEN_EXPIRATION_ACCURACY': datetime.timedelta(minutes=30),
'JWT_PERMANENT_TOKEN_EXPIRATION_DELTA': datetime.timedelta(days=7)
}

# List of settings that may be in string import notation.
Expand Down
55 changes: 52 additions & 3 deletions rest_framework_jwt/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
from django.utils.encoding import force_text
from django.utils.translation import ugettext_lazy as _
from rest_framework import mixins, status, viewsets
from rest_framework.exceptions import APIException, NotFound
from rest_framework.generics import DestroyAPIView
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
Expand All @@ -7,14 +11,30 @@
from .models import Device
from .settings import api_settings
from .serializers import (
DeviceSerializer, JSONWebTokenSerializer, RefreshJSONWebTokenSerializer,
DeviceSerializer, DeviceTokenRefreshSerializer, JSONWebTokenSerializer, RefreshJSONWebTokenSerializer,
VerifyJSONWebTokenSerializer
)

jwt_response_payload_handler = api_settings.JWT_RESPONSE_PAYLOAD_HANDLER


class JSONWebTokenAPIView(APIView):
class HeaderDisallowed(APIException):
status_code = status.HTTP_400_BAD_REQUEST
default_detail = _('Using the {header} header is disallowed for {view_name}.')

def __init__(self, header, view_name, detail=None):
self.detail = force_text(self.default_detail).format(header=header, view_name=view_name)


class HeadersCheckMixin(object):
def initial(self, request, *args, **kwargs):
if (api_settings.JWT_PERMANENT_TOKEN_AUTH and request.META.get('permanent_token') and
type(self) != DeviceRefreshJSONWebToken):
raise HeaderDisallowed('permanent_token', type(self).__name__)
super(HeadersCheckMixin, self).initial(request, *args, **kwargs)


class JSONWebTokenAPIView(HeadersCheckMixin, APIView):
"""
Base API View that various JWT interactions inherit from.
"""
Expand Down Expand Up @@ -106,7 +126,34 @@ class RefreshJSONWebToken(JSONWebTokenAPIView):
serializer_class = RefreshJSONWebTokenSerializer


class DeviceViewSet(mixins.ListModelMixin, mixins.DestroyModelMixin, viewsets.GenericViewSet):
class DeviceRefreshJSONWebToken(HeadersCheckMixin, APIView):
"""
API View used to refresh JSON Web Token using permanent token.
"""
serializer_class = DeviceTokenRefreshSerializer

def post(self, request):
serializer = self.serializer_class(data=request.META)
if serializer.is_valid(raise_exception=True):
data = jwt_response_payload_handler(request=request, **serializer.validated_data)
return Response(data, status=status.HTTP_200_OK)


class DeviceLogout(HeadersCheckMixin, DestroyAPIView):
"""
Logout user by deleting Device.
"""
queryset = Device.objects.all()
permission_classes = [IsAuthenticated]

def get_object(self):
try:
return self.get_queryset().get(user=self.request.user, id=self.request.META.get('device_id'))
except Device.DoesNotExist:
raise NotFound(_('Device does not exist.'))


class DeviceViewSet(HeadersCheckMixin, mixins.ListModelMixin, mixins.DestroyModelMixin, viewsets.GenericViewSet):
queryset = Device.objects.all()
serializer_class = DeviceSerializer
permission_classes = [IsAuthenticated]
Expand All @@ -118,3 +165,5 @@ def get_queryset(self):
obtain_jwt_token = ObtainJSONWebToken.as_view()
refresh_jwt_token = RefreshJSONWebToken.as_view()
verify_jwt_token = VerifyJSONWebToken.as_view()
device_refresh_token = DeviceRefreshJSONWebToken.as_view()
device_logout = DeviceLogout.as_view()
148 changes: 148 additions & 0 deletions tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django import get_version
from django.test import TestCase
from django.test.utils import override_settings
from freezegun import freeze_time
from rest_framework import status
from rest_framework.test import APIClient

Expand Down Expand Up @@ -180,6 +181,7 @@ def test_jwt_permanent_token_auth(self):
self.assertEqual(device.name, 'agent')
self.assertEqual(device.details, '')
self.assertEqual(Device.objects.all().count(), 1)
self.assertEqual(Device.objects.get(permanent_token=response.data['permanent_token']).name, 'agent')

# check if the generated token works
client.credentials(HTTP_AUTHORIZATION='JWT {}'.format(token))
Expand Down Expand Up @@ -534,6 +536,120 @@ def tearDown(self):
api_settings.JWT_ALLOW_REFRESH = DEFAULTS['JWT_ALLOW_REFRESH']


class DeviceLogoutViewTests(BaseTestCase):
def setUp(self):
super(DeviceLogoutViewTests, self).setUp()
self.second_user = User.objects.create_user(
self.username + '2', self.email + '2', self.password)

api_settings.JWT_PERMANENT_TOKEN_AUTH = True

def test_logout_view(self):
client = APIClient(enforce_csrf_checks=True)

# create device
headers = {'HTTP_X_DEVICE_MODEL': 'Android 123'}
client.credentials(**headers)
response = client.post('/auth-token/', self.data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(Device.objects.all().count(), 1)
device_id = response.data['device_id']

headers['HTTP_AUTHORIZATION'] = 'JWT {}'.format(response.data['token'])
headers['device_id'] = device_id
client.credentials(**headers)
client.login(**self.data)
response = client.delete('/device-logout/', format='json')
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertEqual(Device.objects.all().count(), 0)

def test_logout_unknown_device(self):
client = APIClient(enforce_csrf_checks=True)

# create a few devices
headers = {'HTTP_X_DEVICE_MODEL': 'Android 123'}
client.credentials(**headers)
response = client.post('/auth-token/', self.data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
token = response.data['token']

headers['HTTP_X_DEVICE_MODEL'] = 'Nokia'
client.credentials(**headers)
response = client.post('/auth-token/', {'username': self.second_user.username, 'password': self.password},
format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(Device.objects.all().count(), 2)
device_id = response.data['device_id']

headers['HTTP_AUTHORIZATION'] = 'JWT {}'.format(token)
headers['device_id'] = device_id
client.credentials(**headers)
client.login(**self.data)
response = client.delete('/device-logout/', format='json')
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(Device.objects.all().count(), 2)

def tearDown(self):
api_settings.JWT_PERMANENT_TOKEN_AUTH = False


class DeviceRefreshTokenViewsTests(BaseTestCase):
def setUp(self):
super(DeviceRefreshTokenViewsTests, self).setUp()
api_settings.JWT_PERMANENT_TOKEN_AUTH = True

def test_refreshing(self):
with freeze_time('2016-01-01 00:00:00') as frozen_time:
client = APIClient(enforce_csrf_checks=True)

headers = {'HTTP_X_DEVICE_MODEL': 'Android 123'}
client.credentials(**headers)
response = client.post('/auth-token/', self.data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
permanent_token = response.data['permanent_token']
old_token = response.data['token']

frozen_time.tick(delta=timedelta(days=2))
# test w/o passing permanent_token
response = client.post('/device-refresh-token/', format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

# test passing permanent token that does not exist in the database
fake_permanent_token = '23124csfdgfdhthfdfdf'
self.assertEqual(Device.objects.filter(permanent_token=fake_permanent_token).count(), 0)
headers['permanent_token'] = fake_permanent_token
client.credentials(**headers)
response = client.post('/device-refresh-token/', format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

headers['permanent_token'] = permanent_token
client.credentials(**headers)
response = client.post('/device-refresh-token/', format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(set(response.data.keys()), {'token'})
device = Device.objects.get(permanent_token=permanent_token)
self.assertEqual(device.last_request_datetime, datetime.now())
token = response.data['token']
self.assertNotEqual(token, old_token)

# test auth with the new token
client.credentials(HTTP_AUTHORIZATION='JWT {}'.format(token))
client.login(**self.data)
response = client.get('/devices/')
self.assertEqual(response.status_code, 200)

# test permanent token expiration
frozen_time.tick(delta=timedelta(days=8))
client.credentials(**headers)
response = client.post('/device-refresh-token/', format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
with self.assertRaises(Device.DoesNotExist):
Device.objects.get(permanent_token=permanent_token)

def tearDown(self):
api_settings.JWT_PERMANENT_TOKEN_AUTH = False


class DeviceViewTests(TokenTestCase):
def setUp(self):
super(DeviceViewTests, self).setUp()
Expand Down Expand Up @@ -575,3 +691,35 @@ def test_device_list(self):
'id', 'created', 'name', 'details', 'last_request_datetime'
})
self.assertEqual(response.data[0]['id'], self.device.id)


class HeadersCheckViewMixinTests(BaseTestCase):
def setUp(self):
super(HeadersCheckViewMixinTests, self).setUp()
api_settings.JWT_PERMANENT_TOKEN_AUTH = True

def test_disallowing_permanent_token(self):
client = APIClient(enforce_csrf_checks=True)
client.credentials(permanent_token='123')
urls = [
'/auth-token/',
'/auth-token-refresh/',
'/auth-token-verify/',
'/device-logout/',
'/devices/',
'/devices/1/'
]
for url in urls:
# request method makes no difference here, as the check is done on dispatch
response = client.get(url, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

allowed_urls = [
'/device-refresh-token/'
]
for url in allowed_urls:
response = client.get(url, format='json')
self.assertNotEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

def tearDown(self):
api_settings.JWT_PERMANENT_TOKEN_AUTH = False
2 changes: 2 additions & 0 deletions tests/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ def post(self, request):
url(r'^auth-token/$', views.obtain_jwt_token),
url(r'^auth-token-refresh/$', views.refresh_jwt_token),
url(r'^auth-token-verify/$', views.verify_jwt_token),
url(r'^device-refresh-token/$', views.device_refresh_token),
url(r'^device-logout/$', views.device_logout),

url(r'^jwt/$', MockView.as_view(
authentication_classes=[JSONWebTokenAuthentication])),
Expand Down

0 comments on commit f92fea6

Please sign in to comment.