├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── jwt_auth ├── __init__.py ├── compat.py ├── exceptions.py ├── forms.py ├── mixins.py ├── settings.py ├── utils.py └── views.py ├── requirements-test.txt ├── requirements.txt ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── models.py ├── settings.py ├── test_mixins.py ├── test_views.py ├── urls.py └── views.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | # Rope 46 | .ropeproject 47 | 48 | # Django stuff: 49 | *.log 50 | *.pot 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | sudo: false 4 | 5 | env: 6 | - TOX_ENV=py35-django19 7 | - TOX_ENV=py34-django19 8 | - TOX_ENV=py27-django19 9 | - TOX_ENV=py34-django18 10 | - TOX_ENV=py33-django18 11 | - TOX_ENV=py27-django18 12 | - TOX_ENV=py34-django17 13 | - TOX_ENV=py33-django17 14 | - TOX_ENV=py27-django17 15 | 16 | matrix: 17 | # Python 3.5 not yet available on travis, watch this to see when it is. 18 | fast_finish: true 19 | allow_failures: 20 | - env: TOX_ENV=py35-django19 21 | 22 | install: 23 | - pip install tox 24 | 25 | script: 26 | - tox -e $TOX_ENV 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 José Padilla 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include requirements.txt 3 | recursive-exclude * __pycache__ 4 | recursive-exclude * *.py[co] 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django JWT Auth 2 | 3 | [![build-status-image]][travis] 4 | [![pypi-version]][pypi] 5 | 6 | ## Overview 7 | This package provides [JSON Web Token Authentication](http://tools.ietf.org/html/draft-ietf-oauth-json-web-token) support for Django. 8 | 9 | Based on the [Django REST Framework JWT Auth](https://github.com/GetBlimp/django-rest-framework-jwt) package. 10 | 11 | ## Installation 12 | 13 | Install using `pip`... 14 | 15 | ``` 16 | $ pip install django-jwt-auth 17 | ``` 18 | 19 | ## Usage 20 | 21 | In your `urls.py` add the following URL route to enable obtaining a token via a POST included the user's username and password. 22 | 23 | ```python 24 | from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token 25 | 26 | urlpatterns = [ 27 | # ... 28 | 29 | url(r'api-token-auth/', obtain_jwt_token), 30 | url(r'api-token-refresh/', refresh_jwt_token), 31 | ] 32 | ``` 33 | 34 | You can easily test if the endpoint is working by doing the following in your terminal, if you had a user created with the username **admin** and password **abc123**. 35 | 36 | ```bash 37 | $ curl -X POST -H "Content-Type: application/json" -d '{"username":"admin","password":"abc123"}' http://localhost:8000/api-token-auth/ 38 | ``` 39 | 40 | Now in order to access protected api urls you must include the `Authorization: Bearer ` header. 41 | 42 | ```bash 43 | $ curl -H "Authorization: Bearer " http://localhost:8000/protected-url/ 44 | ``` 45 | 46 | ## Additional Settings 47 | There are some additional settings that you can override similar to how you'd do it with Django REST framework itself. Here are all the available defaults. 48 | 49 | ```python 50 | JWT_ENCODE_HANDLER = 'jwt_auth.utils.jwt_encode_handler' 51 | JWT_DECODE_HANDLER = 'jwt_auth.utils.jwt_decode_handler', 52 | JWT_PAYLOAD_HANDLER = 'jwt_auth.utils.jwt_payload_handler' 53 | JWT_PAYLOAD_GET_USER_ID_HANDLER = 'jwt_auth.utils.jwt_get_user_id_from_payload_handler' 54 | JWT_SECRET_KEY: SECRET_KEY 55 | JWT_ALGORITHM = 'HS256' 56 | JWT_VERIFY = True 57 | JWT_VERIFY_EXPIRATION = True 58 | JWT_LEEWAY = 0 59 | JWT_EXPIRATION_DELTA = datetime.timedelta(seconds=300) 60 | JWT_ALLOW_REFRESH = False 61 | JWT_REFRESH_EXPIRATION_DELTA = datetime.timedelta(days=7) 62 | JWT_AUTH_HEADER_PREFIX = 'Bearer' 63 | ``` 64 | This packages uses the JSON Web Token Python implementation, [PyJWT](https://github.com/progrium/pyjwt) and allows to modify some of it's available options. 65 | 66 | ### JWT_SECRET_KEY 67 | This is the secret key used to encrypt the JWT. Make sure this is safe and not shared or public. 68 | 69 | Default is your project's `settings.SECRET_KEY`. 70 | 71 | ### JWT_ALGORITHM 72 | 73 | Possible values: 74 | 75 | > * HS256 - HMAC using SHA-256 hash algorithm (default) 76 | > * HS384 - HMAC using SHA-384 hash algorithm 77 | > * HS512 - HMAC using SHA-512 hash algorithm 78 | > * RS256 - RSASSA-PKCS1-v1_5 signature algorithm using SHA-256 hash algorithm 79 | > * RS384 - RSASSA-PKCS1-v1_5 signature algorithm using SHA-384 hash algorithm 80 | > * RS512 - RSASSA-PKCS1-v1_5 signature algorithm using SHA-512 hash algorithm 81 | 82 | Note: 83 | > For the RSASSA-PKCS1-v1_5 algorithms, the "secret" argument in jwt.encode is supposed to be a private RSA key as 84 | > imported with Crypto.PublicKey.RSA.importKey. Likewise, the "secret" argument in jwt.decode is supposed to be the 85 | > public RSA key imported with the same method. 86 | 87 | Default is `"HS256"`. 88 | 89 | ### JWT_VERIFY 90 | 91 | If the secret is wrong, it will raise a jwt.DecodeError telling you as such. You can still get at the payload by setting the `JWT_VERIFY` to `False`. 92 | 93 | Default is `True`. 94 | 95 | ### JWT_VERIFY_EXPIRATION 96 | 97 | You can turn off expiration time verification with by setting `JWT_VERIFY_EXPIRATION` to `False`. 98 | 99 | Default is `True`. 100 | 101 | ### JWT_LEEWAY 102 | 103 | > This allows you to validate an expiration time which is in the past but no very far. For example, if you have a JWT payload with an expiration time set to 30 seconds after creation but you know that sometimes you will process it after 30 seconds, you can set a leeway of 10 seconds in order to have some margin. 104 | 105 | Default is `0` seconds. 106 | 107 | ### JWT_EXPIRATION_DELTA 108 | This is an instance of Python's `datetime.timedelta`. This will be added to `datetime.utcnow()` to set the expiration time. 109 | 110 | Default is `datetime.timedelta(seconds=300)`(5 minutes). 111 | 112 | ### JWT_ALLOW_REFRESH 113 | Enable token refresh functionality. Token issued from `rest_framework_jwt.views.obtain_jwt_token` will have an `orig_iat` field. Default is `False` 114 | 115 | ### JWT_REFRESH_EXPIRATION_DELTA 116 | Limit on token refresh, is a `datetime.timedelta` instance. This is how much time after the original token that future tokens can be refreshed from. 117 | 118 | Default is `datetime.timedelta(days=7)` (7 days). 119 | 120 | ### JWT_PAYLOAD_HANDLER 121 | Specify a custom function to generate the token payload 122 | 123 | ### JWT_PAYLOAD_GET_USER_ID_HANDLER 124 | If you store `user_id` differently than the default payload handler does, implement this function to fetch `user_id` from the payload. 125 | 126 | ### JWT_AUTH_HEADER_PREFIX 127 | You can modify the Authorization header value prefix that is required to be sent together with the token. 128 | 129 | Default is `Bearer`. 130 | 131 | 132 | [build-status-image]: https://secure.travis-ci.org/jpadilla/django-jwt-auth.svg?branch=master 133 | [travis]: http://travis-ci.org/jpadilla/django-jwt-auth?branch=master 134 | [pypi-version]: https://img.shields.io/pypi/v/django-jwt-auth.svg 135 | [pypi]: https://pypi.python.org/pypi/django-jwt-auth 136 | -------------------------------------------------------------------------------- /jwt_auth/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | django-jwt-auth library 3 | ----------------------- 4 | 5 | Several utilities to implement JWT Authentication in Django. 6 | 7 | :copyright: (c) 2014 by Jose Padilla 8 | :license: MIT. See LICENSE for more details 9 | """ 10 | 11 | __title__ = 'jwt_auth' 12 | __version__ = '0.0.2' 13 | __author__ = 'Jose Padilla' 14 | __license__ = 'MIT' 15 | __copyright__ = 'Copyright 2014 Jose Padilla' 16 | -------------------------------------------------------------------------------- /jwt_auth/compat.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | try: 4 | import json 5 | except ImportError: 6 | from django.utils import simplejson as json 7 | 8 | try: 9 | from django.contrib.auth import get_user_model 10 | except ImportError: 11 | from django.contrib.auth.models import User 12 | else: 13 | User = get_user_model() 14 | 15 | try: 16 | from django.utils.encoding import smart_text 17 | except ImportError: 18 | from django.utils.encoding import smart_unicode as smart_text 19 | 20 | try: 21 | # In 1.5 the test client uses force_bytes 22 | from django.utils.encoding import force_bytes as force_bytes_or_smart_bytes 23 | except ImportError: 24 | # In 1.4 the test client just uses smart_str 25 | from django.utils.encoding import smart_str as force_bytes_or_smart_bytes 26 | -------------------------------------------------------------------------------- /jwt_auth/exceptions.py: -------------------------------------------------------------------------------- 1 | class AuthenticationFailed(Exception): 2 | status_code = 401 3 | detail = 'Incorrect authentication credentials.' 4 | 5 | def __init__(self, detail=None): 6 | self.detail = detail or self.detail 7 | 8 | def __str__(self): 9 | return self.detail 10 | -------------------------------------------------------------------------------- /jwt_auth/forms.py: -------------------------------------------------------------------------------- 1 | from calendar import timegm 2 | from datetime import datetime 3 | 4 | from django import forms 5 | from django.contrib.auth import authenticate 6 | 7 | from jwt_auth import settings 8 | from jwt_auth.compat import User 9 | 10 | 11 | jwt_payload_handler = settings.JWT_PAYLOAD_HANDLER 12 | jwt_encode_handler = settings.JWT_ENCODE_HANDLER 13 | jwt_decode_handler = settings.JWT_DECODE_HANDLER 14 | jwt_get_user_id_from_payload = settings.JWT_PAYLOAD_GET_USER_ID_HANDLER 15 | 16 | 17 | class JSONWebTokenForm(forms.Form): 18 | password = forms.CharField() 19 | 20 | def __init__(self, *args, **kwargs): 21 | super(JSONWebTokenForm, self).__init__(*args, **kwargs) 22 | 23 | # Dynamically add the USERNAME_FIELD to self.fields. 24 | self.fields[self.username_field] = forms.CharField() 25 | 26 | @property 27 | def username_field(self): 28 | try: 29 | return User.USERNAME_FIELD 30 | except AttributeError: 31 | return 'username' 32 | 33 | def clean(self): 34 | cleaned_data = super(JSONWebTokenForm, self).clean() 35 | credentials = { 36 | self.username_field: cleaned_data.get(self.username_field), 37 | 'password': cleaned_data.get('password') 38 | } 39 | 40 | if all(credentials.values()): 41 | user = authenticate(**credentials) 42 | 43 | if user: 44 | if not user.is_active: 45 | msg = 'User account is disabled.' 46 | raise forms.ValidationError(msg) 47 | 48 | payload = jwt_payload_handler(user) 49 | 50 | # Include original issued at time for a brand new token, 51 | # to allow token refresh 52 | if settings.JWT_ALLOW_REFRESH: 53 | payload['orig_iat'] = timegm( 54 | datetime.utcnow().utctimetuple() 55 | ) 56 | 57 | self.object = { 58 | 'token': jwt_encode_handler(payload) 59 | } 60 | else: 61 | msg = 'Unable to login with provided credentials.' 62 | raise forms.ValidationError(msg) 63 | else: 64 | msg = 'Must include "username" and "password"' 65 | raise forms.ValidationError(msg) 66 | -------------------------------------------------------------------------------- /jwt_auth/mixins.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | import jwt 4 | from django.utils.decorators import method_decorator 5 | from django.views.decorators.csrf import csrf_exempt 6 | 7 | from jwt_auth import settings, exceptions 8 | from jwt_auth.utils import get_authorization_header 9 | from jwt_auth.compat import json, smart_text, User 10 | 11 | 12 | jwt_decode_handler = settings.JWT_DECODE_HANDLER 13 | jwt_get_user_id_from_payload = settings.JWT_PAYLOAD_GET_USER_ID_HANDLER 14 | 15 | 16 | class JSONWebTokenAuthMixin(object): 17 | """ 18 | Token based authentication using the JSON Web Token standard. 19 | 20 | Clients should authenticate by passing the token key in the "Authorization" 21 | HTTP header, prepended with the string specified in the setting 22 | `JWT_AUTH_HEADER_PREFIX`. For example: 23 | 24 | Authorization: JWT eyJhbGciOiAiSFMyNTYiLCAidHlwIj 25 | """ 26 | www_authenticate_realm = 'api' 27 | 28 | @method_decorator(csrf_exempt) 29 | def dispatch(self, request, *args, **kwargs): 30 | try: 31 | request.user, request.token = self.authenticate(request) 32 | except exceptions.AuthenticationFailed as e: 33 | response = HttpResponse( 34 | json.dumps({'errors': [str(e)]}), 35 | status=401, 36 | content_type='application/json' 37 | ) 38 | 39 | response['WWW-Authenticate'] = self.authenticate_header(request) 40 | 41 | return response 42 | 43 | return super(JSONWebTokenAuthMixin, self).dispatch( 44 | request, *args, **kwargs) 45 | 46 | def authenticate(self, request): 47 | auth = get_authorization_header(request).split() 48 | auth_header_prefix = settings.JWT_AUTH_HEADER_PREFIX.lower() 49 | 50 | if not auth or smart_text(auth[0].lower()) != auth_header_prefix: 51 | raise exceptions.AuthenticationFailed() 52 | 53 | if len(auth) == 1: 54 | msg = 'Invalid Authorization header. No credentials provided.' 55 | raise exceptions.AuthenticationFailed(msg) 56 | elif len(auth) > 2: 57 | msg = ('Invalid Authorization header. Credentials string ' 58 | 'should not contain spaces.') 59 | raise exceptions.AuthenticationFailed(msg) 60 | 61 | try: 62 | payload = jwt_decode_handler(auth[1]) 63 | except jwt.ExpiredSignature: 64 | msg = 'Signature has expired.' 65 | raise exceptions.AuthenticationFailed(msg) 66 | except jwt.DecodeError: 67 | msg = 'Error decoding signature.' 68 | raise exceptions.AuthenticationFailed(msg) 69 | 70 | user = self.authenticate_credentials(payload) 71 | 72 | return (user, auth[1]) 73 | 74 | def authenticate_credentials(self, payload): 75 | """ 76 | Returns an active user that matches the payload's user id and email. 77 | """ 78 | try: 79 | user_id = jwt_get_user_id_from_payload(payload) 80 | 81 | if user_id: 82 | user = User.objects.get(pk=user_id, is_active=True) 83 | else: 84 | msg = 'Invalid payload' 85 | raise exceptions.AuthenticationFailed(msg) 86 | except User.DoesNotExist: 87 | msg = 'Invalid signature' 88 | raise exceptions.AuthenticationFailed(msg) 89 | 90 | return user 91 | 92 | def authenticate_header(self, request): 93 | """ 94 | Return a string to be used as the value of the `WWW-Authenticate` 95 | header in a `401 Unauthenticated` response, or `None` if the 96 | authentication scheme should return `403 Permission Denied` responses. 97 | """ 98 | return 'JWT realm="{0}"'.format(self.www_authenticate_realm) 99 | -------------------------------------------------------------------------------- /jwt_auth/settings.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.conf import settings 4 | 5 | from jwt_auth.utils import import_from_string 6 | 7 | 8 | JWT_ENCODE_HANDLER = import_from_string(getattr( 9 | settings, 10 | 'JWT_ENCODE_HANDLER', 11 | 'jwt_auth.utils.jwt_encode_handler') 12 | ) 13 | 14 | JWT_DECODE_HANDLER = import_from_string(getattr( 15 | settings, 16 | 'JWT_DECODE_HANDLER', 17 | 'jwt_auth.utils.jwt_decode_handler') 18 | ) 19 | 20 | JWT_PAYLOAD_HANDLER = import_from_string(getattr( 21 | settings, 22 | 'JWT_PAYLOAD_HANDLER', 23 | 'jwt_auth.utils.jwt_payload_handler') 24 | ) 25 | 26 | JWT_PAYLOAD_GET_USER_ID_HANDLER = import_from_string(getattr( 27 | settings, 28 | 'JWT_PAYLOAD_GET_USER_ID_HANDLER', 29 | 'jwt_auth.utils.jwt_get_user_id_from_payload_handler') 30 | ) 31 | 32 | JWT_SECRET_KEY = getattr( 33 | settings, 34 | 'JWT_SECRET_KEY', 35 | settings.SECRET_KEY 36 | ) 37 | 38 | JWT_ALGORITHM = getattr(settings, 'JWT_ALGORITHM', 'HS256') 39 | 40 | JWT_VERIFY = getattr(settings, 'JWT_VERIFY', True) 41 | 42 | JWT_VERIFY_EXPIRATION = getattr(settings, 'JWT_VERIFY_EXPIRATION', True) 43 | 44 | JWT_LEEWAY = getattr(settings, 'JWT_LEEWAY', 0) 45 | 46 | JWT_EXPIRATION_DELTA = getattr( 47 | settings, 48 | 'JWT_EXPIRATION_DELTA', 49 | datetime.timedelta(seconds=300) 50 | ) 51 | 52 | JWT_ALLOW_REFRESH = getattr(settings, 'JWT_ALLOW_REFRESH', False) 53 | 54 | JWT_REFRESH_EXPIRATION_DELTA = getattr( 55 | settings, 56 | 'JWT_REFRESH_EXPIRATION_DELTA', 57 | datetime.timedelta(seconds=300) 58 | ) 59 | 60 | JWT_AUTH_HEADER_PREFIX = getattr(settings, 'JWT_AUTH_HEADER_PREFIX', 'Bearer') 61 | -------------------------------------------------------------------------------- /jwt_auth/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from datetime import datetime 3 | import importlib 4 | 5 | import jwt 6 | 7 | 8 | def jwt_payload_handler(user): 9 | from jwt_auth import settings 10 | 11 | try: 12 | username = user.get_username() 13 | except AttributeError: 14 | username = user.username 15 | 16 | return { 17 | 'user_id': user.pk, 18 | 'email': user.email, 19 | 'username': username, 20 | 'exp': datetime.utcnow() + settings.JWT_EXPIRATION_DELTA 21 | } 22 | 23 | 24 | def jwt_get_user_id_from_payload_handler(payload): 25 | """ 26 | Override this function if user_id is formatted differently in payload 27 | """ 28 | user_id = payload.get('user_id') 29 | return user_id 30 | 31 | 32 | def jwt_encode_handler(payload): 33 | from jwt_auth import settings 34 | 35 | return jwt.encode( 36 | payload, 37 | settings.JWT_SECRET_KEY, 38 | settings.JWT_ALGORITHM 39 | ).decode('utf-8') 40 | 41 | 42 | def jwt_decode_handler(token): 43 | from jwt_auth import settings 44 | 45 | options = { 46 | 'verify_exp': settings.JWT_VERIFY_EXPIRATION, 47 | } 48 | 49 | return jwt.decode( 50 | token, 51 | settings.JWT_SECRET_KEY, 52 | settings.JWT_VERIFY, 53 | options=options, 54 | leeway=settings.JWT_LEEWAY 55 | ) 56 | 57 | 58 | def import_from_string(val): 59 | """ 60 | Attempt to import a class from a string representation. 61 | 62 | From: https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/settings.py 63 | """ 64 | try: 65 | # Nod to tastypie's use of importlib. 66 | parts = val.split('.') 67 | module_path, class_name = '.'.join(parts[:-1]), parts[-1] 68 | module = importlib.import_module(module_path) 69 | return getattr(module, class_name) 70 | except ImportError as e: 71 | msg = "Could not import '%s' for setting. %s: %s." % (val, e.__class__.__name__, e) 72 | raise ImportError(msg) 73 | 74 | 75 | def get_authorization_header(request): 76 | """ 77 | Return request's 'Authorization:' header, as a bytestring. 78 | From: https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/authentication.py 79 | """ 80 | auth = request.META.get('HTTP_AUTHORIZATION', b'') 81 | 82 | if isinstance(auth, type('')): 83 | # Work around django test client oddness 84 | auth = auth.encode('iso-8859-1') 85 | 86 | return auth 87 | -------------------------------------------------------------------------------- /jwt_auth/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse, HttpResponseBadRequest 2 | from django.utils.decorators import method_decorator 3 | from django.views.decorators.csrf import csrf_exempt 4 | from django.views.generic import View 5 | from django.core.serializers.json import DjangoJSONEncoder 6 | 7 | from jwt_auth.compat import json, smart_text 8 | from jwt_auth.forms import JSONWebTokenForm 9 | 10 | 11 | class ObtainJSONWebToken(View): 12 | http_method_names = ['post'] 13 | error_response_dict = {'errors': ['Improperly formatted request']} 14 | json_encoder_class = DjangoJSONEncoder 15 | 16 | @method_decorator(csrf_exempt) 17 | def dispatch(self, request, *args, **kwargs): 18 | return super(ObtainJSONWebToken, self).dispatch(request, *args, **kwargs) 19 | 20 | def post(self, request, *args, **kwargs): 21 | try: 22 | request_json = json.loads(smart_text(request.body)) 23 | except ValueError: 24 | return self.render_bad_request_response() 25 | 26 | form = JSONWebTokenForm(request_json) 27 | 28 | if not form.is_valid(): 29 | return self.render_bad_request_response({'errors': form.errors}) 30 | 31 | context_dict = { 32 | 'token': form.object['token'] 33 | } 34 | 35 | return self.render_response(context_dict) 36 | 37 | def render_response(self, context_dict): 38 | json_context = json.dumps(context_dict, cls=self.json_encoder_class) 39 | 40 | return HttpResponse(json_context, content_type='application/json') 41 | 42 | def render_bad_request_response(self, error_dict=None): 43 | if error_dict is None: 44 | error_dict = self.error_response_dict 45 | 46 | json_context = json.dumps(error_dict, cls=self.json_encoder_class) 47 | 48 | return HttpResponseBadRequest( 49 | json_context, content_type='application/json') 50 | 51 | 52 | obtain_jwt_token = ObtainJSONWebToken.as_view() 53 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | flake8==2.2.3 2 | pytest==2.8.5 3 | pytest-django==2.9.1 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyJWT>=1.4.0,<2.0.0 2 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | """ 3 | From https://github.com/tomchristie/django-rest-framework/blob/bf09c32de8f9d528f83e9cb7a2773d1f4c9ab563/runtests.py 4 | """ 5 | from __future__ import print_function 6 | 7 | import pytest 8 | import sys 9 | import os 10 | import subprocess 11 | 12 | 13 | PYTEST_ARGS = { 14 | 'default': ['tests'], 15 | 'fast': ['tests', '-q'], 16 | } 17 | 18 | FLAKE8_ARGS = ['jwt_auth', 'tests', '--ignore=E501'] 19 | 20 | 21 | sys.path.append(os.path.dirname(__file__)) 22 | 23 | def exit_on_failure(ret, message=None): 24 | if ret: 25 | sys.exit(ret) 26 | 27 | def flake8_main(args): 28 | print('Running flake8 code linting') 29 | ret = subprocess.call(['flake8'] + args) 30 | print('flake8 failed' if ret else 'flake8 passed') 31 | return ret 32 | 33 | def split_class_and_function(string): 34 | class_string, function_string = string.split('.', 1) 35 | return "%s and %s" % (class_string, function_string) 36 | 37 | def is_function(string): 38 | # `True` if it looks like a test function is included in the string. 39 | return string.startswith('test_') or '.test_' in string 40 | 41 | def is_class(string): 42 | # `True` if first character is uppercase - assume it's a class name. 43 | return string[0] == string[0].upper() 44 | 45 | 46 | if __name__ == "__main__": 47 | try: 48 | sys.argv.remove('--nolint') 49 | except ValueError: 50 | run_flake8 = True 51 | else: 52 | run_flake8 = False 53 | 54 | try: 55 | sys.argv.remove('--lintonly') 56 | except ValueError: 57 | run_tests = True 58 | else: 59 | run_tests = False 60 | 61 | try: 62 | sys.argv.remove('--fast') 63 | except ValueError: 64 | style = 'default' 65 | else: 66 | style = 'fast' 67 | run_flake8 = False 68 | 69 | if len(sys.argv) > 1: 70 | pytest_args = sys.argv[1:] 71 | first_arg = pytest_args[0] 72 | if first_arg.startswith('-'): 73 | # `runtests.py [flags]` 74 | pytest_args = ['tests'] + pytest_args 75 | elif is_class(first_arg) and is_function(first_arg): 76 | # `runtests.py TestCase.test_function [flags]` 77 | expression = split_class_and_function(first_arg) 78 | pytest_args = ['tests', '-k', expression] + pytest_args[1:] 79 | elif is_class(first_arg) or is_function(first_arg): 80 | # `runtests.py TestCase [flags]` 81 | # `runtests.py test_function [flags]` 82 | pytest_args = ['tests', '-k', pytest_args[0]] + pytest_args[1:] 83 | else: 84 | pytest_args = PYTEST_ARGS[style] 85 | 86 | if run_tests: 87 | exit_on_failure(pytest.main(pytest_args)) 88 | if run_flake8: 89 | exit_on_failure(flake8_main(FLAKE8_ARGS)) 90 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | import os 6 | import sys 7 | from setuptools import setup 8 | from setuptools.command.test import test as TestCommand 9 | 10 | 11 | # This command has been borrowed from 12 | # https://github.com/getsentry/sentry/blob/master/setup.py 13 | class PyTest(TestCommand): 14 | def finalize_options(self): 15 | TestCommand.finalize_options(self) 16 | self.test_args = ['tests'] 17 | self.test_suite = True 18 | 19 | def run_tests(self): 20 | import pytest 21 | errno = pytest.main(self.test_args) 22 | sys.exit(errno) 23 | 24 | 25 | name = 'django-jwt-auth' 26 | package = 'jwt_auth' 27 | description = 'JSON Web Token based authentication for Django' 28 | url = 'https://github.com/jpadilla/django-jwt-auth' 29 | author = 'Jose Padilla' 30 | author_email = 'hello@jpadilla.com' 31 | license = 'MIT' 32 | install_requires = open('requirements.txt').read().split('\n') 33 | 34 | 35 | def get_version(package): 36 | """ 37 | Return package version as listed in `__version__` in `init.py`. 38 | """ 39 | init_py = open(os.path.join(package, '__init__.py')).read() 40 | return re.search("^__version__ = ['\"]([^'\"]+)['\"]", 41 | init_py, re.MULTILINE).group(1) 42 | 43 | 44 | def get_packages(package): 45 | """ 46 | Return root package and all sub-packages. 47 | """ 48 | return [dirpath 49 | for dirpath, dirnames, filenames in os.walk(package) 50 | if os.path.exists(os.path.join(dirpath, '__init__.py'))] 51 | 52 | 53 | def get_package_data(package): 54 | """ 55 | Return all files under the root package, that are not in a 56 | package themselves. 57 | """ 58 | walk = [(dirpath.replace(package + os.sep, '', 1), filenames) 59 | for dirpath, dirnames, filenames in os.walk(package) 60 | if not os.path.exists(os.path.join(dirpath, '__init__.py'))] 61 | 62 | filepaths = [] 63 | for base, filenames in walk: 64 | filepaths.extend([os.path.join(base, filename) 65 | for filename in filenames]) 66 | return {package: filepaths} 67 | 68 | 69 | version = get_version(package) 70 | 71 | 72 | if sys.argv[-1] == 'publish': 73 | os.system("python setup.py sdist upload") 74 | os.system("python setup.py bdist_wheel upload") 75 | print("You probably want to also tag the version now:") 76 | print(" git tag -a {0} -m 'version {0}'".format(version)) 77 | print(" git push --tags") 78 | sys.exit() 79 | 80 | 81 | setup( 82 | name=name, 83 | version=version, 84 | url=url, 85 | license=license, 86 | description=description, 87 | author=author, 88 | author_email=author_email, 89 | packages=get_packages(package), 90 | package_data=get_package_data(package), 91 | cmdclass={'test': PyTest}, 92 | install_requires=install_requires, 93 | classifiers=[ 94 | 'Development Status :: 5 - Production/Stable', 95 | 'Environment :: Web Environment', 96 | 'Framework :: Django', 97 | 'Intended Audience :: Developers', 98 | 'License :: OSI Approved :: MIT License', 99 | 'Operating System :: OS Independent', 100 | 'Programming Language :: Python', 101 | 'Programming Language :: Python :: 3', 102 | 'Topic :: Internet :: WWW/HTTP', 103 | ] 104 | ) 105 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpadilla/django-jwt-auth/aecab610613e3ac5be9dc09be3e2968c5da0cd3b/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import django 4 | from django.conf import settings 5 | 6 | 7 | def pytest_configure(): 8 | if not settings.configured: 9 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 10 | 11 | try: 12 | django.setup() 13 | except AttributeError: 14 | pass 15 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpadilla/django-jwt-auth/aecab610613e3ac5be9dc09be3e2968c5da0cd3b/tests/models.py -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | DEBUG = False 2 | TEMPLATE_DEBUG = DEBUG 3 | 4 | TIME_ZONE = 'UTC' 5 | LANGUAGE_CODE = 'en-US' 6 | SITE_ID = 1 7 | USE_L10N = True 8 | USE_TZ = True 9 | 10 | SECRET_KEY = 'dont-tell-eve' 11 | 12 | ROOT_URLCONF = 'tests.urls' 13 | 14 | DATABASES = { 15 | 'default': { 16 | 'ENGINE': 'django.db.backends.sqlite3', 17 | 'NAME': ':memory:', 18 | } 19 | } 20 | 21 | MIDDLEWARE_CLASSES = [ 22 | 'django.middleware.common.CommonMiddleware', 23 | 'django.contrib.sessions.middleware.SessionMiddleware', 24 | 'django.middleware.csrf.CsrfViewMiddleware', 25 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 26 | 'django.contrib.messages.middleware.MessageMiddleware', 27 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 28 | ] 29 | 30 | TEMPLATE_CONTEXT_PROCESSORS = [ 31 | 'django.contrib.auth.context_processors.auth', 32 | 'django.core.context_processors.i18n', 33 | 'django.core.context_processors.media', 34 | 'django.core.context_processors.static', 35 | 'django.core.context_processors.tz', 36 | 'django.core.context_processors.request', 37 | 'django.contrib.messages.context_processors.messages' 38 | ] 39 | 40 | STATICFILES_FINDERS = ( 41 | 'django.contrib.staticfiles.finders.FileSystemFinder', 42 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 43 | ) 44 | 45 | INSTALLED_APPS = ( 46 | 'django.contrib.auth', 47 | 'django.contrib.contenttypes', 48 | 'django.contrib.sessions', 49 | 'django.contrib.messages', 50 | 'django.contrib.staticfiles', 51 | 'django.contrib.sites', 52 | 53 | 'tests', 54 | ) 55 | 56 | PASSWORD_HASHERS = ( 57 | 'django.contrib.auth.hashers.MD5PasswordHasher', 58 | ) 59 | -------------------------------------------------------------------------------- /tests/test_mixins.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.test import TestCase 3 | from django.test.client import Client 4 | 5 | from jwt_auth import utils 6 | from jwt_auth.compat import User, json, smart_text 7 | 8 | 9 | @pytest.mark.django_db 10 | class JSONWebTokenAuthMixinTestCase(TestCase): 11 | def setUp(self): 12 | self.email = 'jpueblo@example.com' 13 | self.username = 'jpueblo' 14 | self.password = 'password' 15 | self.user = User.objects.create_user( 16 | self.username, self.email, self.password) 17 | 18 | self.data = { 19 | 'username': self.username, 20 | 'password': self.password 21 | } 22 | 23 | self.client = Client() 24 | 25 | def test_post_json_passing_jwt_auth(self): 26 | """ 27 | Ensure POSTing form over JWT auth with correct credentials 28 | passes and does not require CSRF 29 | """ 30 | payload = utils.jwt_payload_handler(self.user) 31 | token = utils.jwt_encode_handler(payload) 32 | 33 | auth = 'Bearer {0}'.format(token) 34 | response = self.client.post( 35 | '/jwt/', 36 | content_type='application/json', 37 | HTTP_AUTHORIZATION=auth 38 | ) 39 | 40 | response_content = json.loads(smart_text(response.content)) 41 | 42 | self.assertEqual(response.status_code, 200) 43 | self.assertEqual(response_content['username'], self.username) 44 | 45 | def test_post_json_failing_jwt_auth(self): 46 | """ 47 | Ensure POSTing json over JWT auth without correct credentials fails 48 | """ 49 | response = self.client.post('/jwt/', content_type='application/json') 50 | 51 | response_content = json.loads(smart_text(response.content)) 52 | 53 | self.assertEqual(response.status_code, 401) 54 | self.assertEqual(response['WWW-Authenticate'], 'JWT realm="api"') 55 | 56 | expected_error = ['Incorrect authentication credentials.'] 57 | self.assertEqual(response_content['errors'], expected_error) 58 | 59 | def test_post_no_jwt_header_failing_jwt_auth(self): 60 | """ 61 | Ensure POSTing over JWT auth without credentials fails 62 | """ 63 | auth = 'Bearer' 64 | response = self.client.post( 65 | '/jwt/', 66 | content_type='application/json', 67 | HTTP_AUTHORIZATION=auth, 68 | ) 69 | 70 | response_content = json.loads(smart_text(response.content)) 71 | 72 | self.assertEqual(response.status_code, 401) 73 | self.assertEqual(response['WWW-Authenticate'], 'JWT realm="api"') 74 | 75 | expected_error = ['Invalid Authorization header. No credentials provided.'] 76 | self.assertEqual(response_content['errors'], expected_error) 77 | 78 | def test_post_invalid_jwt_header_failing_jwt_auth(self): 79 | """ 80 | Ensure POSTing over JWT auth without correct credentials fails 81 | """ 82 | auth = 'Bearer abc abc' 83 | response = self.client.post( 84 | '/jwt/', 85 | content_type='application/json', 86 | HTTP_AUTHORIZATION=auth 87 | ) 88 | 89 | response_content = json.loads(smart_text(response.content)) 90 | 91 | self.assertEqual(response.status_code, 401) 92 | self.assertEqual(response['WWW-Authenticate'], 'JWT realm="api"') 93 | 94 | expected_error = ['Invalid Authorization header. Credentials string should not contain spaces.'] 95 | self.assertEqual(response_content['errors'], expected_error) 96 | 97 | def test_post_expired_token_failing_jwt_auth(self): 98 | """ 99 | Ensure POSTing over JWT auth with expired token fails 100 | """ 101 | payload = utils.jwt_payload_handler(self.user) 102 | payload['exp'] = 1 103 | token = utils.jwt_encode_handler(payload) 104 | 105 | auth = 'Bearer {0}'.format(token) 106 | response = self.client.post( 107 | '/jwt/', 108 | content_type='application/json', 109 | HTTP_AUTHORIZATION=auth 110 | ) 111 | 112 | response_content = json.loads(smart_text(response.content)) 113 | 114 | self.assertEqual(response.status_code, 401) 115 | self.assertEqual(response['WWW-Authenticate'], 'JWT realm="api"') 116 | 117 | expected_error = ['Signature has expired.'] 118 | self.assertEqual(response_content['errors'], expected_error) 119 | 120 | def test_post_invalid_token_failing_jwt_auth(self): 121 | """ 122 | Ensure POSTing over JWT auth with invalid token fails 123 | """ 124 | auth = 'Bearer abc123' 125 | response = self.client.post( 126 | '/jwt/', 127 | content_type='application/json', 128 | HTTP_AUTHORIZATION=auth 129 | ) 130 | 131 | response_content = json.loads(smart_text(response.content)) 132 | 133 | self.assertEqual(response.status_code, 401) 134 | self.assertEqual(response['WWW-Authenticate'], 'JWT realm="api"') 135 | 136 | expected_error = ['Error decoding signature.'] 137 | self.assertEqual(response_content['errors'], expected_error) 138 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.test import TestCase 3 | from django.test.client import Client 4 | 5 | from jwt_auth import utils 6 | from jwt_auth.compat import json, User, smart_text 7 | 8 | 9 | @pytest.mark.django_db 10 | class ObtainJSONWebTokenTestCase(TestCase): 11 | def setUp(self): 12 | self.email = 'jpueblo@example.com' 13 | self.username = 'jpueblo' 14 | self.password = 'password' 15 | self.user = User.objects.create_user( 16 | self.username, self.email, self.password) 17 | 18 | self.data = { 19 | 'username': self.username, 20 | 'password': self.password 21 | } 22 | 23 | self.client = Client() 24 | 25 | def test_jwt_login_json(self): 26 | """ 27 | Ensure JWT login view using JSON POST works. 28 | """ 29 | response = self.client.post( 30 | '/auth-token/', 31 | json.dumps(self.data), 32 | content_type='application/json' 33 | ) 34 | 35 | response_content = json.loads(smart_text(response.content)) 36 | 37 | decoded_payload = utils.jwt_decode_handler(response_content['token']) 38 | 39 | self.assertEqual(response.status_code, 200) 40 | self.assertEqual(decoded_payload['username'], self.username) 41 | 42 | def test_jwt_login_json_bad_creds(self): 43 | """ 44 | Ensure JWT login view using JSON POST fails 45 | if bad credentials are used. 46 | """ 47 | self.data['password'] = 'wrong' 48 | 49 | response = self.client.post( 50 | '/auth-token/', 51 | json.dumps(self.data), 52 | content_type='application/json' 53 | ) 54 | 55 | self.assertEqual(response.status_code, 400) 56 | 57 | def test_jwt_login_json_missing_fields(self): 58 | """ 59 | Ensure JWT login view using JSON POST fails if missing fields. 60 | """ 61 | response = self.client.post( 62 | '/auth-token/', 63 | json.dumps({'username': self.username}), 64 | content_type='application/json' 65 | ) 66 | 67 | self.assertEqual(response.status_code, 400) 68 | 69 | def test_jwt_login_with_expired_token(self): 70 | """ 71 | Ensure JWT login view works even if expired token is provided 72 | """ 73 | payload = utils.jwt_payload_handler(self.user) 74 | payload['exp'] = 1 75 | token = utils.jwt_encode_handler(payload) 76 | 77 | auth = 'Bearer {0}'.format(token) 78 | 79 | response = self.client.post( 80 | '/auth-token/', 81 | json.dumps(self.data), 82 | content_type='application/json', 83 | HTTP_AUTHORIZATION=auth 84 | ) 85 | 86 | response_content = json.loads(smart_text(response.content)) 87 | 88 | decoded_payload = utils.jwt_decode_handler(response_content['token']) 89 | 90 | self.assertEqual(response.status_code, 200) 91 | self.assertEqual(decoded_payload['username'], self.username) 92 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns 2 | 3 | from tests.views import MockView 4 | 5 | 6 | urlpatterns = patterns( 7 | '', 8 | (r'^jwt/$', MockView.as_view()), 9 | (r'^auth-token/$', 'jwt_auth.views.obtain_jwt_token'), 10 | ) 11 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import View 2 | from django.http import HttpResponse 3 | 4 | from jwt_auth.compat import json 5 | from jwt_auth.mixins import JSONWebTokenAuthMixin 6 | 7 | 8 | class MockView(JSONWebTokenAuthMixin, View): 9 | def post(self, request): 10 | data = json.dumps({'username': request.user.username}) 11 | return HttpResponse(data) 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts=--tb=short 3 | 4 | [tox] 5 | envlist = 6 | {py27,py33,py34}-django{17,18}, 7 | {py27,py34,py35}-django{19} 8 | 9 | [testenv] 10 | commands = ./runtests.py --fast {posargs} 11 | setenv = 12 | PYTHONDONTWRITEBYTECODE=1 13 | deps = 14 | django17: Django==1.7.11 15 | django18: Django==1.8.7 16 | django19: Django==1.9 17 | -rrequirements.txt 18 | -rrequirements-test.txt 19 | --------------------------------------------------------------------------------