diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..304a0cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,93 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Sublime Text +*.sublime* + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +manage.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..211601f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,67 @@ +language: python + +cache: pip +sudo: false + +env: + - TOX_ENV=py27-flake8 + + - TOX_ENV=py27-django1.8-drf3.1 + - TOX_ENV=py27-django1.8-drf3.2 + - TOX_ENV=py27-django1.8-drf3.3 + - TOX_ENV=py27-django1.8-drf3.4 + - TOX_ENV=py27-django1.8-drf3.5 + + - TOX_ENV=py27-django1.9-drf3.1 + - TOX_ENV=py27-django1.9-drf3.2 + - TOX_ENV=py27-django1.9-drf3.3 + - TOX_ENV=py27-django1.9-drf3.4 + - TOX_ENV=py27-django1.9-drf3.5 + + - TOX_ENV=py27-django1.10-drf3.4 + - TOX_ENV=py27-django1.10-drf3.5 + + - TOX_ENV=py27-django1.11-drf3.4 + - TOX_ENV=py27-django1.11-drf3.5 + + - TOX_ENV=py34-django1.8-drf3.1 + - TOX_ENV=py34-django1.8-drf3.2 + - TOX_ENV=py34-django1.8-drf3.3 + - TOX_ENV=py34-django1.8-drf3.4 + - TOX_ENV=py34-django1.8-drf3.5 + + - TOX_ENV=py34-django1.9-drf3.1 + - TOX_ENV=py34-django1.9-drf3.2 + - TOX_ENV=py34-django1.9-drf3.3 + - TOX_ENV=py34-django1.9-drf3.4 + - TOX_ENV=py34-django1.9-drf3.5 + + - TOX_ENV=py34-django1.10-drf3.4 + - TOX_ENV=py34-django1.10-drf3.5 + + - TOX_ENV=py34-django1.11-drf3.4 + - TOX_ENV=py34-django1.11-drf3.5 + + - TOX_ENV=py35-django1.10-drf3.4 + - TOX_ENV=py35-django1.10-drf3.5 + + - TOX_ENV=py35-django1.11-drf3.4 + - TOX_ENV=py35-django1.11-drf3.5 + +matrix: + fast_finish: true + +install: + - pip install tox coverage coveralls + +script: + - tox -e $TOX_ENV + +after_success: + - coverage report -m + - coveralls + +notifications: + email: + recipients: + - team@arabel.la diff --git a/CHANGELOG.txt b/CHANGELOG.txt new file mode 100644 index 0000000..819fd75 --- /dev/null +++ b/CHANGELOG.txt @@ -0,0 +1,2 @@ +# Change Log +All notable changes to this project will be documented in this file. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7461871 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Arabella + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..ddfcce8 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include README.rst +include LICENSE +include requirements/* +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] diff --git a/README.md b/README.md deleted file mode 100644 index 2ab2da9..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# drf-jwt-devices -Permanent token authentication for django-rest-framework-jwt diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..71f3a53 --- /dev/null +++ b/README.rst @@ -0,0 +1,113 @@ +=============== +drf-jwt-devices +=============== +|travis|_ |pypi|_ |coveralls|_ |requiresio|_ + +Permanent token feature for `Django Rest Framework JWT `_ + +By default JWT tokens have short lifetime because of security reasons, but sometimes you may want to keep user logged +in, without the need to refresh the auth token each 5 minutes. For this case, you should consider using the permanent +token authentication. + +Installation +============ +To use, add ``jwt_devices`` to your ``INSTALLED_APPS``, and then migrate the project. + +Configuration +------------- + +To enable permanent token authentication, update rest framework's default authentication classes list: +:: + + REST_FRAMEWORK={ + "DEFAULT_AUTHENTICATION_CLASSES": [ + "jwt_devices.authentication.PermanentTokenAuthentication" + ] + } + +Another step is to add a few urls to your url patterns, and register the ``DeviceViewSet``: +:: + + from jwt_devices import views + from rest_framework.routers import DefaultRouter + + router = DefaultRouter() + router.register(r'devices', views.DeviceViewSet) + + urlpatterns = [ + # ... + url(r'^device-refresh-token/$', views.device_refresh_token), + url(r'^device-logout/$', views.device_logout) + ] + router.urls + + +Using the API views +------------------- + +**Login & logout view** + +When using the regular JWT login or the device logout view, use the ``X-Device-Model`` header to pass device model +(otherwise, user agent will used instead as the name). After a successful login, the permanent token and id of the +created device will be returned, for example: +:: + + { + "token": "ads344fdgfd5454yJ0eAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VynRlYW1AYXJhYmVsLmxh", + "permanent_token": "gfd5454yJ0eAiOiJKV1QiLCJhbGciOiJ", + "device_id": 1 + } + +The ``device_id`` is used to logout the device, so it should be saved on the front-end side (in local storage, for +example). + +To logout a device, make a **DELETE** request to the ``rest_framework_jwt.views.device_logout`` view, passing device's +id in the ``Device-Id`` header to identify the device. + +**Refresh JWT token using permanent token** + +To refresh JWT token, you have to pass the ``Permanent-Token`` header along with the request to identify the device. +On success, response will return new JWT token (the same as it does after login). + +In case the permanent token has expired, the device will be logged out, and it will require login in again to obtain a +new permanent token. To customize the expiration time and expiration accuracy, set the following settings in your +``REST_FRAMEWORK`` configuration in **settings.py** + + +**PermitHeaders middleware** + +As you may know, the content of a permanent token is a very fragile information, which should be sent along with a +request only when it is needed. To avoid situations in which a front-end developer has incorrectly implemented the +permanent token authentication on the front-end side and the permanent token value is sent with all requests +(just like the JWT token), the ``jwt_devices.middleware.PermitHeadersMiddleware`` comes in handy. The middleware looks +for ``Permanent-Token`` in the headers, and checks if the view is not the +``jwt_devices.views.DeviceRefreshJSONWebToken`` in which the ``Permanent-Token`` header is obligatory, otherwise it +returns a **400 Bad Request** error. + +To use the ``PermitHeadersMiddleware`` in your application, add ``jwt_devices.middleware.PermitHeadersMiddleware`` +to your ``MIDDLEWARES`` or ``MIDDLEWARE_CLASSES`` (in Django <1.10) in Django settings. + +**Settings** + +* ``JWT_PERMANENT_TOKEN_AUTH`` - option to enable/disable the permanent token authentication (default: ``True``) +* ``JWT_PERMANENT_TOKEN_EXPIRATION_DELTA`` - describes how long can the permanent token live + (default: ``datetime.timedelta(days=7)``) +* ``JWT_PERMANENT_TOKEN_EXPIRATION_ACCURACY`` - the accuracy of updating permanent token last request time to decrease + the number of database queries (default: ``datetime.timedelta(minutes=30)``) + +Support +======= +* Django 1.8 - 1.11 +* Django Rest Framework 3.1 - 3.5 +* Python 2.7, 3.4, 3.5, 3.6 + +.. |travis| image:: https://secure.travis-ci.org/ArabellaTech/drf-jwt-devices.svg?branch=master +.. _travis: http://travis-ci.org/ArabellaTech/drf-jwt-devices + +.. |pypi| image:: https://img.shields.io/pypi/v/drf-jwt-devices.svg +.. _pypi: https://pypi.python.org/pypi/drf-jwt-devices + +.. |coveralls| image:: https://coveralls.io/repos/github/ArabellaTech/drf-jwt-devices/badge.svg?branch=master +.. _coveralls: https://coveralls.io/github/ArabellaTech/drf-jwt-devices + +.. |requiresio| image:: https://requires.io/github/ArabellaTech/drf-jwt-devices/requirements.svg?branch=master +.. _requiresio: https://requires.io/github/ArabellaTech/drf-jwt-devices/requirements/ diff --git a/jwt_devices/__init__.py b/jwt_devices/__init__.py new file mode 100644 index 0000000..d3db12f --- /dev/null +++ b/jwt_devices/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +__title__ = "Django Rest Framework JWT Devices" +__version__ = "0.1" +__author__ = "Michal Proszek" +__license__ = "MIT" +__copyright__ = "Copyright 2017 Arabella" + +# Version synonym +VERSION = __version__ diff --git a/jwt_devices/authentication.py b/jwt_devices/authentication.py new file mode 100644 index 0000000..2d60704 --- /dev/null +++ b/jwt_devices/authentication.py @@ -0,0 +1,35 @@ +import jwt +from django.utils.translation import ugettext as _ +from rest_framework import exceptions +from rest_framework_jwt.authentication import JSONWebTokenAuthentication +from rest_framework_jwt.settings import api_settings as rfj_settings + +from jwt_devices.settings import api_settings + +jwt_decode_handler = rfj_settings.JWT_DECODE_HANDLER +jwt_devices_decode_handler = api_settings.JWT_DEVICES_DECODE_HANDLER + + +class PermanentTokenAuthentication(JSONWebTokenAuthentication): + def authenticate(self, request): + jwt_value = self.get_jwt_value(request) + if jwt_value is None: + return None + + try: + if api_settings.JWT_PERMANENT_TOKEN_AUTH: + payload = jwt_devices_decode_handler(jwt_value) + else: + payload = jwt_decode_handler(jwt_value) + except jwt.ExpiredSignature: + msg = _("Signature has expired.") + raise exceptions.AuthenticationFailed(msg) + except jwt.DecodeError: + msg = _("Error decoding signature.") + raise exceptions.AuthenticationFailed(msg) + except jwt.InvalidTokenError: + raise exceptions.AuthenticationFailed() + + user = self.authenticate_credentials(payload) + + return user, jwt_value diff --git a/jwt_devices/middleware.py b/jwt_devices/middleware.py new file mode 100644 index 0000000..4c0caae --- /dev/null +++ b/jwt_devices/middleware.py @@ -0,0 +1,30 @@ +from django.http.response import JsonResponse +from django.utils.translation import ugettext_lazy as _ +from rest_framework import status + +from jwt_devices import views +from jwt_devices.settings import api_settings + + +class PermitHeadersMiddleware(object): + """ + Middleware used to disallow sending the permanent_token header in other requests than during permanent token + refresh to make sure naive FE developers do not send the fragile permanent token with each request. + """ + + def __init__(self, get_response=None): + self.get_response = get_response + + def __call__(self, request): + if self.get_response: + return self.get_response(request) + + def process_view(self, request, view_func, view_args, view_kwargs): + view_cls = getattr(view_func, "cls", None) + if (view_cls and api_settings.JWT_PERMANENT_TOKEN_AUTH and request.META.get("HTTP_PERMANENT_TOKEN") and + view_cls != views.DeviceRefreshJSONWebToken): + return JsonResponse({ + "HTTP_PERMANENT_TOKEN": { + "details": _("Using the Permanent-Token header is disallowed for {}").format(type(view_cls)) + } + }, status=status.HTTP_400_BAD_REQUEST) diff --git a/jwt_devices/migrations/0001_initial.py b/jwt_devices/migrations/0001_initial.py new file mode 100644 index 0000000..0ceaed1 --- /dev/null +++ b/jwt_devices/migrations/0001_initial.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-07-21 08:06 +from __future__ import unicode_literals + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Device', + fields=[ + ('permanent_token', models.CharField(max_length=255, unique=True, serialize=False)), + ('jwt_secret', models.UUIDField(default=uuid.uuid4, editable=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('name', models.CharField(max_length=255, verbose_name='Device name')), + ('details', models.CharField(blank=True, max_length=255, verbose_name='Device details')), + ('last_request_datetime', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/jwt_devices/migrations/__init__.py b/jwt_devices/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jwt_devices/models.py b/jwt_devices/models.py new file mode 100644 index 0000000..5f80c0e --- /dev/null +++ b/jwt_devices/models.py @@ -0,0 +1,29 @@ +import binascii +import os +import uuid + +from django.conf import settings +from django.db import models +from django.utils.translation import ugettext_lazy as _ + + +class Device(models.Model): + """ + Device model used for permanent token authentication + """ + permanent_token = models.CharField(max_length=255, unique=True) + jwt_secret = models.UUIDField(default=uuid.uuid4, editable=False) + created = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + name = models.CharField(_("Device name"), max_length=255) + details = models.CharField(_("Device details"), max_length=255, blank=True) + last_request_datetime = models.DateTimeField(auto_now=True) + + def save(self, *args, **kwargs): + if not self.permanent_token: + self.permanent_token = self.generate_key() + + return super(Device, self).save(*args, **kwargs) + + def generate_key(self): + return binascii.hexlify(os.urandom(20)).decode() diff --git a/jwt_devices/serializers.py b/jwt_devices/serializers.py new file mode 100644 index 0000000..b55a4e5 --- /dev/null +++ b/jwt_devices/serializers.py @@ -0,0 +1,100 @@ +from datetime import datetime + +from django.contrib.auth import authenticate, get_user_model +from django.utils.translation import ugettext as _ +from rest_framework import serializers +from rest_framework_jwt.compat import Serializer +from rest_framework_jwt.serializers import JSONWebTokenSerializer as OriginalJSONWebTokenSerializer +from rest_framework_jwt.settings import api_settings as rfj_settings + +from jwt_devices.models import Device +from jwt_devices.settings import api_settings + +User = get_user_model() + +jwt_payload_handler = rfj_settings.JWT_PAYLOAD_HANDLER +jwt_encode_handler = rfj_settings.JWT_ENCODE_HANDLER +jwt_devices_payload_handler = api_settings.JWT_DEVICES_PAYLOAD_HANDLER +jwt_devices_encode_handler = api_settings.JWT_DEVICES_ENCODE_HANDLER + + +class JSONWebTokenSerializer(OriginalJSONWebTokenSerializer): + """ + Serializer used to obtain permanent token. + """ + def validate(self, attrs): + credentials = { + self.username_field: attrs.get(self.username_field), + "password": attrs.get("password") + } + + if all(credentials.values()): + user = authenticate(**credentials) + + if user: + if not user.is_active: + msg = _("User account is disabled.") + raise serializers.ValidationError(msg) + + data = { + "user": user + } + 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", "") + if not device_name: + device_name = user_agent + device_details = "" + else: + device_details = user_agent + + device = Device.objects.create( + user=user, last_request_datetime=datetime.now(), + name=device_name, details=device_details) + + data["token"] = jwt_devices_encode_handler(jwt_devices_payload_handler(user, device=device)) + data["device"] = device + else: + data["token"] = jwt_encode_handler(jwt_payload_handler(user)) + + return data + else: + msg = _("Unable to log in with provided credentials.") + raise serializers.ValidationError(msg) + else: + msg = _("Must include \"{username_field}\" and \"password\".") + msg = msg.format(username_field=self.username_field) + raise serializers.ValidationError(msg) + + +class DeviceSerializer(serializers.ModelSerializer): + class Meta: + model = Device + fields = ["id", "created", "name", "details", "last_request_datetime"] + + +class DeviceTokenRefreshSerializer(Serializer): + HTTP_PERMANENT_TOKEN = serializers.CharField(required=True) + + def validate(self, attrs): + permanent_token = attrs["HTTP_PERMANENT_TOKEN"] + try: + device = Device.objects.get(permanent_token=permanent_token) + except Device.DoesNotExist: + raise serializers.ValidationError({"HTTP_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({"HTTP_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_devices_payload_handler(device.user, device=device) + return { + "token": jwt_devices_encode_handler(payload), + "user": device.user + } diff --git a/jwt_devices/settings.py b/jwt_devices/settings.py new file mode 100644 index 0000000..ad0b31d --- /dev/null +++ b/jwt_devices/settings.py @@ -0,0 +1,34 @@ +import datetime + +from django.conf import settings +from rest_framework.settings import APISettings + +USER_SETTINGS = getattr(settings, "JWT_DEVICES", None) + +DEFAULTS = { + "JWT_PERMANENT_TOKEN_AUTH": True, + "JWT_PERMANENT_TOKEN_EXPIRATION_ACCURACY": datetime.timedelta(minutes=30), + "JWT_PERMANENT_TOKEN_EXPIRATION_DELTA": datetime.timedelta(days=7), + + "JWT_DEVICES_RESPONSE_PAYLOAD_HANDLER": + "jwt_devices.utils.jwt_devices_response_payload_handler", + + "JWT_DEVICES_PAYLOAD_HANDLER": + "jwt_devices.utils.jwt_devices_payload_handler", + + "JWT_DEVICES_ENCODE_HANDLER": + "jwt_devices.utils.jwt_devices_encode_handler", + + "JWT_DEVICES_DECODE_HANDLER": + "jwt_devices.utils.jwt_devices_decode_handler", +} + +IMPORT_STRINGS = ( + "JWT_DEVICES_RESPONSE_PAYLOAD_HANDLER", + "JWT_DEVICES_PAYLOAD_HANDLER", + "JWT_DEVICES_ENCODE_HANDLER", + "JWT_DEVICES_DECODE_HANDLER", +) +# List of settings that may be in string import notation. + +api_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS) diff --git a/jwt_devices/utils.py b/jwt_devices/utils.py new file mode 100644 index 0000000..605e316 --- /dev/null +++ b/jwt_devices/utils.py @@ -0,0 +1,58 @@ +import jwt +from rest_framework_jwt.settings import api_settings as rfj_settings + +from jwt_devices.models import Device + +jwt_payload_handler = rfj_settings.JWT_PAYLOAD_HANDLER +jwt_response_payload_handler = rfj_settings.JWT_RESPONSE_PAYLOAD_HANDLER + + +def jwt_devices_get_secret_key(payload=None): + return Device.objects.get(pk=payload.get("device_id")).jwt_secret.hex + + +def jwt_devices_payload_handler(user, device=None): + payload = jwt_payload_handler(user) + payload["device_id"] = str(device.pk) + return payload + + +def jwt_devices_encode_handler(payload): + return jwt.encode( + payload, + jwt_devices_get_secret_key(payload), + rfj_settings.JWT_ALGORITHM + ).decode("utf-8") + + +def jwt_devices_response_payload_handler(token, user=None, request=None, **kwargs): + """ + Returns the response data for both the login and refresh views. + """ + data = jwt_response_payload_handler(token, user, request) + permanent_token = kwargs.get("permanent_token") + if permanent_token: + data["permanent_token"] = permanent_token + device_id = kwargs.get("device_id") + if device_id: + data["device_id"] = device_id + + return data + + +def jwt_devices_decode_handler(token): + options = { + "verify_exp": rfj_settings.JWT_VERIFY_EXPIRATION, + } + # get user from token, BEFORE verification, to get user secret key + unverified_payload = jwt.decode(token, None, False) + return jwt.decode( + token, + jwt_devices_get_secret_key(unverified_payload), + rfj_settings.JWT_VERIFY, + options=options, + leeway=rfj_settings.JWT_LEEWAY, + audience=rfj_settings.JWT_AUDIENCE, + issuer=rfj_settings.JWT_ISSUER, + algorithms=[rfj_settings.JWT_ALGORITHM] + ) diff --git a/jwt_devices/views.py b/jwt_devices/views.py new file mode 100644 index 0000000..02d3fdc --- /dev/null +++ b/jwt_devices/views.py @@ -0,0 +1,97 @@ +from datetime import datetime + +from django.utils.translation import ugettext_lazy as _ +from rest_framework import mixins, status, viewsets +from rest_framework.exceptions import NotFound +from rest_framework.generics import DestroyAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework_jwt.settings import api_settings as rfj_settings +from rest_framework_jwt.views import ObtainJSONWebToken as OriginalObtainJSONWebToken + +from jwt_devices.models import Device +from jwt_devices.serializers import DeviceSerializer, DeviceTokenRefreshSerializer, JSONWebTokenSerializer +from jwt_devices.settings import api_settings + +jwt_response_payload_handler = rfj_settings.JWT_RESPONSE_PAYLOAD_HANDLER +jwt_devices_response_payload_handler = api_settings.JWT_DEVICES_RESPONSE_PAYLOAD_HANDLER + + +class ObtainJSONWebTokenAPIView(OriginalObtainJSONWebToken): + """ + API view used to obtain a JWT token along with creating a new Device object and returning permanent token. + """ + serializer_class = JSONWebTokenSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + + if serializer.is_valid(): + user = serializer.object.get("user") or request.user + token = serializer.object.get("token") + device = serializer.object.get("device", None) + kwargs = {} + if device: + kwargs.update(dict(permanent_token=device.permanent_token, device_id=device.id)) + + if api_settings.JWT_PERMANENT_TOKEN_AUTH: + response_data = jwt_devices_response_payload_handler(token, user, request, **kwargs) + else: + response_data = jwt_response_payload_handler(token, user, request) + + response = Response(response_data) + if rfj_settings.JWT_AUTH_COOKIE: + expiration = (datetime.utcnow() + + rfj_settings.JWT_EXPIRATION_DELTA) + response.set_cookie(rfj_settings.JWT_AUTH_COOKIE, + token, + expires=expiration, + httponly=True) + return response + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class DeviceRefreshJSONWebToken(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_devices_response_payload_handler(request=request, **serializer.validated_data) + return Response(data, status=status.HTTP_200_OK) + + +class DeviceLogout(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("HTTP_DEVICE_ID")) + except Device.DoesNotExist: + raise NotFound(_("Device does not exist.")) + + +class DeviceViewSet(mixins.ListModelMixin, mixins.DestroyModelMixin, viewsets.GenericViewSet): + """ + Simple viewset to list and delete Device objects related to user. + """ + queryset = Device.objects.all() + serializer_class = DeviceSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return self.queryset.filter(user=self.request.user) + + +obtain_jwt_token = ObtainJSONWebTokenAPIView.as_view() +device_refresh_token = DeviceRefreshJSONWebToken.as_view() +device_logout = DeviceLogout.as_view() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e1d7ec6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +-r requirements/requirements-base.txt +-r requirements/requirements-testing.txt +-r requirements/requirements-codestyle.txt diff --git a/requirements/requirements-base.txt b/requirements/requirements-base.txt new file mode 100644 index 0000000..8e8cc8e --- /dev/null +++ b/requirements/requirements-base.txt @@ -0,0 +1,2 @@ +djangorestframework-jwt>=1.11.0 +six>=1.10.0 diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt new file mode 100644 index 0000000..4446219 --- /dev/null +++ b/requirements/requirements-codestyle.txt @@ -0,0 +1,3 @@ +flake8>=3.2.1 +pycodestyle>=2.2.0 +isort>=4.2.5 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt new file mode 100644 index 0000000..6c68547 --- /dev/null +++ b/requirements/requirements-testing.txt @@ -0,0 +1,5 @@ +coverage>=4.4.1 +pytest>=3.1.1 +pytest-cov>=2.5.1 +pytest-django>=3.1.2 +freezegun>=0.3.9 diff --git a/run_isort b/run_isort new file mode 100755 index 0000000..e112f41 --- /dev/null +++ b/run_isort @@ -0,0 +1 @@ +isort --recursive -p jwt_devices -sd THIRDPARTY -m 0 -w 120 -y -s venv -s .tox "$@" diff --git a/runtests.py b/runtests.py new file mode 100755 index 0000000..80b3c88 --- /dev/null +++ b/runtests.py @@ -0,0 +1,122 @@ +#! /usr/bin/env python +# This script is taken from Django Rest Framework +# (https://github.com/tomchristie/django-rest-framework/blob/master/runtests.py) +from __future__ import print_function + +import os +import subprocess +import sys + +import pytest + +PYTEST_ARGS = { + "default": ["tests", "--tb=short", "-s", "-rw"], + "fast": ["tests", "--tb=short", "-q", "-s", "-rw"], +} +FLAKE8_ARGS = ["jwt_devices", "tests", "--ignore=E501"] + +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 isort_main(): + print("Running isort code checking") + ret = subprocess.call(["sh", "run_isort", "--check-only"]) + + if ret: + print("isort failed: Some modules have incorrectly ordered imports. Fix by running `./run_isort`") + else: + print("isort passed") + + return ret + + +def split_class_and_function(string): + class_string, function_string = string.split(".", 1) + return "{} and {}".format(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() + + +if __name__ == "__main__": + """ test runner - to be used by tox """ + try: + sys.argv.remove("--nolint") + except ValueError: + run_flake8 = True + run_isort = True + else: + run_flake8 = False + run_isort = False + + try: + sys.argv.remove("--lintonly") + except ValueError: + run_tests = True + else: + run_tests = False + + try: + sys.argv.remove("--fast") + except ValueError: + style = "default" + else: + style = "fast" + run_flake8 = False + run_isort = False + + if len(sys.argv) > 1: + pytest_args = sys.argv[1:] + first_arg = pytest_args[0] + + try: + pytest_args.remove("--coverage") + except ValueError: + pass + else: + pytest_args = [ + "--cov-report", + "xml", + "--cov", + "jwt_devices"] + pytest_args + + if first_arg.startswith("-"): + # `runtests.py [flags]` + pytest_args = ["tests"] + pytest_args + elif is_class(first_arg) and is_function(first_arg): + # `runtests.py TestCase.test_function [flags]` + expression = split_class_and_function(first_arg) + pytest_args = ["tests", "-k", expression] + pytest_args[1:] + elif is_class(first_arg) or is_function(first_arg): + # `runtests.py TestCase [flags]` + # `runtests.py test_function [flags]` + pytest_args = ["tests", "-k", pytest_args[0]] + pytest_args[1:] + else: + pytest_args = PYTEST_ARGS[style] + + if run_tests: + exit_on_failure(pytest.main(pytest_args)) + + if run_flake8: + exit_on_failure(flake8_main(FLAKE8_ARGS)) + + if run_isort: + exit_on_failure(isort_main()) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..87cb642 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[metadata] +description-file = README.rst + +[bdist_wheel] +universal=1 diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..9322e28 --- /dev/null +++ b/setup.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import io +import os +import re + +from setuptools import setup + + +def local_open(fname): + return open(os.path.join(os.path.dirname(__file__), fname)) + + +def read_file(f): + return io.open(f, "r", encoding="utf-8").read() + + +def get_version(package): + """ + Return package version as listed in `__version__` in `init.py`. + """ + init_py = open(os.path.join(package, "__init__.py")).read() + return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) + + +def get_packages(package): + """ + Return root package and all sub-packages. + """ + return [dirpath + for dirpath, dirnames, filenames in os.walk(package) + if os.path.exists(os.path.join(dirpath, "__init__.py"))] + + +def get_package_data(package): + """ + Return all files under the root package, that are not in a + package themselves. + """ + walk = [(dirpath.replace(package + os.sep, "", 1), filenames) + for dirpath, dirnames, filenames in os.walk(package) + if not os.path.exists(os.path.join(dirpath, "__init__.py"))] + + filepaths = [] + for base, filenames in walk: + filepaths.extend([os.path.join(base, filename) + for filename in filenames]) + return {package: filepaths} + + +version = get_version("jwt_devices") + + +requirements = local_open("requirements/requirements-base.txt") +required_to_install = [dist.strip() for dist in requirements.readlines()] + + +setup( + name="drf-jwt-devices", + version=version, + url="https://github.com/ArabellaTech/drf-jwt-devices", + license="MIT", + description="Permanent token authentication for django-rest-framework-jwt", + long_description=read_file("README.rst"), + author="Michal Proszek", + author_email="poxip@arabel.la", + packages=get_packages("jwt_devices"), + package_data=get_package_data("jwt_devices"), + zip_safe=False, + install_requires=required_to_install, + classifiers=[ + "Development Status :: 3 - Alpha", + "Environment :: Web Environment", + "Framework :: Django", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Topic :: Internet :: WWW/HTTP", + ] +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..affb63d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,81 @@ +def pytest_configure(): + import django + from django.conf import settings + + settings.configure( + DEBUG_PROPAGATE_EXCEPTIONS=True, + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:" + } + }, + SITE_ID=1, + SECRET_KEY="not very secret in tests", + USE_I18N=True, + USE_L10N=True, + STATIC_URL="/static/", + ROOT_URLCONF="tests.urls", + TEMPLATE_LOADERS=( + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ), + MIDDLEWARE_CLASSES=( + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "jwt_devices.middleware.PermitHeadersMiddleware", + ), + INSTALLED_APPS=( + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", + + "rest_framework", + "rest_framework_jwt", + "jwt_devices", + "tests", + ), + PASSWORD_HASHERS=( + "django.contrib.auth.hashers.MD5PasswordHasher", + ), + REST_FRAMEWORK={ + "DEFAULT_AUTHENTICATION_CLASSES": [ + "jwt_devices.authentication.PermanentTokenAuthentication" + ] + } + ) + + try: + import oauth_provider # NOQA + import oauth2 # NOQA + except ImportError: + pass + else: + settings.INSTALLED_APPS += ( + "oauth_provider", + ) + + try: + if django.VERSION >= (1, 8): + # django-oauth2-provider does not support Django1.8 + raise ImportError + import provider # NOQA + except ImportError: + pass + else: + settings.INSTALLED_APPS += ( + "provider", + "provider.oauth2", + ) + + try: + import django + django.setup() + except AttributeError: + pass diff --git a/tests/test_middleware.py b/tests/test_middleware.py new file mode 100644 index 0000000..2e69809 --- /dev/null +++ b/tests/test_middleware.py @@ -0,0 +1,26 @@ +from rest_framework import status +from rest_framework.test import APIClient +from tests.test_utils import BaseTestCase + + +class HeadersCheckViewMixinTests(BaseTestCase): + def test_disallowing_permanent_token(self): + client = APIClient() + client.credentials(HTTP_PERMANENT_TOKEN="123") + urls = { + "/auth-token/", + "/device-logout/", + "/devices/", + "/devices/1/" + } + for url in urls: + # request method makes no difference here, as the check is done in middleware + 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) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..0dfb4ac --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,25 @@ +from django.test import TestCase +from rest_framework_jwt.compat import get_user_model + +from jwt_devices.settings import api_settings + +User = get_user_model() + + +class BaseTestCase(TestCase): + 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.data = { + "username": self.username, + "password": self.password + } + + api_settings.JWT_PERMANENT_TOKEN_AUTH = True + + def tearDown(self): + api_settings.JWT_PERMANENT_TOKEN_AUTH = False diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 0000000..1bc1da5 --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,218 @@ +from datetime import datetime, timedelta + +from freezegun import freeze_time +from rest_framework import status +from rest_framework.test import APIClient +from tests.test_utils import BaseTestCase, User + +from jwt_devices.models import Device +from jwt_devices.settings import api_settings + + +class ObtainJSONWebTokenTests(BaseTestCase): + def test_jwt_permanent_token_auth(self): + client = APIClient() + client.credentials(HTTP_X_DEVICE_MODEL="Nokia", HTTP_USER_AGENT="agent") + self.assertEqual(Device.objects.all().count(), 0) + response = client.post("/auth-token/", self.data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(set(response.data.keys()), {"token", "permanent_token", "device_id"}) + device = Device.objects.get(permanent_token=response.data["permanent_token"]) + self.assertEqual(response.data["device_id"], device.id) + self.assertIsNotNone(response.data["token"]) + self.assertEqual(device.name, "Nokia") + self.assertEqual(device.details, "agent") + self.assertEqual(Device.objects.all().count(), 1) + Device.objects.all().delete() + + # test using without setting device model - for example on browser + client.credentials(HTTP_USER_AGENT="agent") + self.assertEqual(Device.objects.all().count(), 0) + response = client.post("/auth-token/", self.data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + token = response.data["token"] + device = Device.objects.get(permanent_token=response.data["permanent_token"]) + self.assertEqual(response.data["device_id"], device.id) + 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)) + client.login(**self.data) + response = client.get("/devices/", format="json") + self.assertEqual(response.status_code, 200) + + def test_default_auth(self): + # the app should allow using the old-style authentication + api_settings.JWT_PERMANENT_TOKEN_AUTH = False + client = APIClient() + client.credentials(HTTP_X_DEVICE_MODEL="Nokia", HTTP_USER_AGENT="agent") + self.assertEqual(Device.objects.all().count(), 0) + response = client.post("/auth-token/", self.data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(set(response.data.keys()), {"token"}) + self.assertIsNotNone(response.data["token"]) + self.assertEqual(Device.objects.all().count(), 0) + token = response.data["token"] + + # check if the generated token works + client.credentials(HTTP_AUTHORIZATION="JWT {}".format(token)) + client.login(**self.data) + response = client.get("/devices/", format="json") + self.assertEqual(response.status_code, 200) + api_settings.JWT_PERMANENT_TOKEN_AUTH = True + + +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) + + def test_logout_view(self): + client = APIClient() + + # 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["HTTP_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() + + # 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["HTTP_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) + + +class DeviceRefreshTokenViewsTests(BaseTestCase): + def setUp(self): + super(DeviceRefreshTokenViewsTests, self).setUp() + + def test_refreshing(self): + with freeze_time("2016-01-01 00:00:00") as frozen_time: + client = APIClient() + + 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["HTTP_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["HTTP_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) + + +class DeviceViewTests(BaseTestCase): + def setUp(self): + super(DeviceViewTests, self).setUp() + self.device = Device.objects.create( + user=self.user, permanent_token="somestring2", name="Android", + last_request_datetime=datetime.now()) + self.user2 = User.objects.create_user(email="jsmith@example.com", username="jsmith", password="password") + self.device2 = Device.objects.create( + user=self.user2, permanent_token="somestring98", name="Android", + last_request_datetime=datetime.now()) + + def _get_token(self): + client = APIClient() + response = client.post("/auth-token/", self.data, format="json") + return response.data["token"] + + def _login(self, client): + client.credentials(HTTP_AUTHORIZATION="JWT {}".format(self._get_token())) + return client.login(**self.data) + + def test_device_delete(self): + client = APIClient() + # test accessing without being logged in + response = client.delete("/devices/{}/".format(self.device.id)) + self.assertEqual(response.status_code, 401) + + self._login(client) + # try removing device linked to other user + response = client.delete("/devices/{}/".format(self.device2.id)) + self.assertEqual(response.status_code, 404) + # test regular case + self.assertEqual(Device.objects.filter(id=self.device.id).count(), 1) + response = client.delete("/devices/{}/".format(self.device.id)) + self.assertEqual(response.status_code, 204) + self.assertEqual(Device.objects.filter(id=self.device.id).count(), 0) + + def test_device_list(self): + client = APIClient() + self._login(client) + response = client.get("/devices/", format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 2) # one created in setUp() and one during _login() + self.assertEqual(set(response.data[0].keys()), { + "id", "created", "name", "details", "last_request_datetime" + }) + self.assertEqual(response.data[0]["id"], self.device.id) diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..85c8d2f --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,13 @@ +from django.conf.urls import url +from rest_framework import routers + +from jwt_devices import views + +router = routers.SimpleRouter() +router.register(r"devices", views.DeviceViewSet) + +urlpatterns = [ + url(r"^auth-token/$", views.obtain_jwt_token), + url(r"^device-refresh-token/$", views.device_refresh_token), + url(r"^device-logout/$", views.device_logout), +] + router.urls diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..6be7396 --- /dev/null +++ b/tox.ini @@ -0,0 +1,28 @@ +[tox] +downloadcache = {toxworkdir}/cache/ +envlist = + py{27,36}-flake8, + {py27,py34,py35,py36}-django{1.8,1.9,1.10,1.11}-drf{3.1,3.2,3.3,3.4,3.5} + +[testenv] +commands = ./runtests.py --fast {posargs} --coverage +setenv = + PYTHONDONTWRITEBYTECODE=1 +deps = + django1.8: Django<1.9 + django1.9: Django<1.10 + django1.10: Django<1.11 + django1.11: Django<2.0 + drf3.1: djangorestframework<3.2 + drf3.2: djangorestframework<3.3 + drf3.3: djangorestframework<3.4 + drf3.4: djangorestframework<3.5 + drf3.5: djangorestframework<3.6 + py27-django{1.8,1.9}-drf{3.1,3.2,3.3,3.4}: djangorestframework-oauth==1.0.1 + -rrequirements/requirements-testing.txt + +[testenv:py27-flake8] +commands = ./runtests.py --lintonly +deps = + -rrequirements/requirements-codestyle.txt + -rrequirements/requirements-testing.txt