├── .bumpversion.cfg ├── .github └── workflows │ ├── python-package.yml │ └── python-publish.yml ├── .gitignore ├── .pylintrc ├── LICENSE ├── MANIFEST.in ├── README.md ├── jwt_auth ├── __init__.py ├── core.py ├── exceptions.py ├── forms.py ├── locale │ └── fr │ │ └── LC_MESSAGES │ │ └── django.po ├── middleware.py ├── mixins.py ├── settings.py ├── utils.py └── views.py ├── manage.py ├── requirements_dev.txt ├── runtests.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── settings.py ├── test_middleware.py ├── test_mixins.py ├── test_views.py ├── urls.py └── views.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.4.0 3 | commit = True 4 | tag = True 5 | files = setup.py 6 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Test Suite 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: [3.8, 3.9] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v3 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | python -m pip install flake8 29 | python ./setup.py install 30 | - name: Lint with flake8 31 | run: | 32 | # stop the build if there are Python syntax errors or undefined names 33 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 34 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 35 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 36 | - name: Test 37 | run: | 38 | python ./setup.py test 39 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python 17 | uses: actions/setup-python@v3 18 | with: 19 | python-version: "3.x" 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install setuptools wheel twine 24 | - name: Build and publish 25 | env: 26 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 27 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 28 | run: | 29 | python setup.py sdist bdist_wheel 30 | twine upload dist/* 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | build/ 10 | dist/ 11 | eggs/ 12 | *.egg-info/ 13 | *.egg 14 | 15 | # Unit test / coverage reports 16 | htmlcov/ 17 | .tox/ 18 | .coverage 19 | .cache 20 | nosetests.xml 21 | coverage.xml 22 | 23 | # Translations 24 | *.mo 25 | 26 | # Editors 27 | .vscode/ 28 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable=missing-docstring,invalid-name,too-few-public-methods 3 | 4 | [FORMAT] 5 | # Maximum number of characters on a single line. 6 | max-line-length=120 7 | -------------------------------------------------------------------------------- /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 LICENSE 3 | include MANIFEST.in 4 | recursive-include jwt_auth *.py 5 | recursive-include jwt_auth/locale/ *.mo 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django JWT Auth 2 | 3 | ![Test Suite](https://github.com/webstack/django-jwt-auth/workflows/Test%20Suite/badge.svg) 4 | [![pypi-version]][pypi] 5 | 6 | ## Overview 7 | 8 | This package provides [JSON Web Token 9 | Authentication](http://tools.ietf.org/html/draft-ietf-oauth-json-web-token) 10 | support for Django by using [PyJWT](https://github.com/jpadilla/pyjwt). 11 | 12 | The project is a fork of (https://github.com/jpadilla/django-jwt-auth) created 13 | by José Padilla (maintainer of PyJWT too). José doesn't seem to have the time 14 | anymore to work on django-jwt-auth. 15 | 16 | New features from original code: 17 | 18 | - refresh token 19 | - provides 2 middlewares 20 | - Django 3.0+ 21 | - better coverage and packaging 22 | 23 | ## Installation 24 | 25 | Install using `pip`... 26 | 27 | ```shell 28 | pip install webstack-django-jwt-auth 29 | ``` 30 | 31 | ## Usage 32 | 33 | In your `urls.py` add the following URL route to enable obtaining a token via a 34 | POST included the user's username and password. 35 | 36 | ```python 37 | from jwt_auth import views as jwt_auth_views 38 | 39 | from your_app.views import RestrictedView 40 | 41 | urlpatterns = [ 42 | # ... 43 | path("token-auth/", jwt_auth_views.jwt_token), 44 | path("token-refresh/", jwt_auth_views.refresh_jwt_token), 45 | path("protected-url/", RestrictedView.as_view()), 46 | ] 47 | ``` 48 | 49 | Inside your_app, create a Django restricted view: 50 | 51 | ```python 52 | import json 53 | 54 | from django.http import JsonResponse 55 | from django.views.generic import View 56 | from jwt_auth.mixins import JSONWebTokenAuthMixin 57 | 58 | class RestrictedView(JSONWebTokenAuthMixin, View): 59 | def get(self, request): 60 | data = { 61 | "foo": "bar", 62 | "username": request.user.username, 63 | } 64 | return JsonResponse(data) 65 | ``` 66 | 67 | You can easily test if the endpoint is working by doing the following in your 68 | terminal, if you had a user created with the username **admin** and password 69 | **abc123**. 70 | 71 | ```shell 72 | curl -X POST -H "Content-Type: application/json" -d '{"username":"admin","password":"abc123"}' http://localhost:8000/token-auth/ 73 | ``` 74 | 75 | Now in order to access protected api urls you must include the `Authorization: Bearer ` header. 76 | 77 | ```shell 78 | curl -H "Authorization: Bearer " http://localhost:8000/protected-url/ 79 | ``` 80 | 81 | There is also a provided middleware if you would prefer that to the view 82 | integration. Just add the following to your middleware: 83 | 84 | ```python 85 | MIDDLEWARE = ( 86 | # ... 87 | 'jwt_auth.middleware.JWTAuthenticationMiddleware', 88 | ) 89 | ``` 90 | 91 | ## Additional Settings 92 | 93 | There are some additional settings that you can override similar to how you'd do 94 | it with Django REST framework itself. Here are all the available defaults. 95 | 96 | ```python 97 | JWT_ALGORITHM = 'HS256' 98 | JWT_ALLOW_REFRESH = False 99 | JWT_AUDIENCE = None 100 | JWT_AUTH_HEADER_PREFIX = 'Bearer' 101 | JWT_DECODE_HANDLER = 'jwt_auth.utils.jwt_decode_handler', 102 | JWT_ENCODE_HANDLER = 'jwt_auth.utils.jwt_encode_handler' 103 | JWT_EXPIRATION_DELTA = datetime.timedelta(seconds=300) 104 | JWT_LEEWAY = 0 105 | JWT_LOGIN_URLS = [settings.LOGIN_URL] 106 | JWT_PAYLOAD_GET_USER_ID_HANDLER = 'jwt_auth.utils.jwt_get_user_id_from_payload_handler' 107 | JWT_PAYLOAD_HANDLER = 'jwt_auth.utils.jwt_payload_handler' 108 | JWT_REFRESH_EXPIRATION_DELTA = datetime.timedelta(days=7) 109 | JWT_SECRET_KEY: SECRET_KEY 110 | JWT_VERIFY = True 111 | JWT_VERIFY_EXPIRATION = True 112 | ``` 113 | 114 | This packages uses the JSON Web Token Python implementation, 115 | [PyJWT](https://github.com/progrium/pyjwt) and allows to modify some of it's 116 | available options. 117 | 118 | ### JWT_ALGORITHM 119 | 120 | Possible values: 121 | 122 | - HS256 - HMAC using SHA-256 hash algorithm (default) 123 | - HS384 - HMAC using SHA-384 hash algorithm 124 | - HS512 - HMAC using SHA-512 hash algorithm 125 | - RS256 - RSASSA-PKCS1-v1_5 signature algorithm using SHA-256 hash algorithm 126 | - RS384 - RSASSA-PKCS1-v1_5 signature algorithm using SHA-384 hash algorithm 127 | - RS512 - RSASSA-PKCS1-v1_5 signature algorithm using SHA-512 hash algorithm 128 | 129 | Note: 130 | 131 | > For the RSASSA-PKCS1-v1_5 algorithms, the "secret" argument in jwt.encode is 132 | > supposed to be a private RSA key as imported with 133 | > Crypto.PublicKey.RSA.importKey. Likewise, the "secret" argument in jwt.decode 134 | > is supposed to be the public RSA key imported with the same method. 135 | 136 | Default is `"HS256"`. 137 | 138 | ### JWT_ALLOW_REFRESH 139 | 140 | Enable token refresh functionality. Token issued from `jwt_auth.views.jwt_token` 141 | will have an `orig_iat` field. 142 | 143 | Default is `False` 144 | 145 | ### JWT_AUDIENCE 146 | 147 | Typically, the base address of the resource being accessed, eg `https://example.com`. 148 | 149 | ### JWT_AUTH_HEADER_PREFIX 150 | 151 | You can modify the Authorization header value prefix that is required to be sent 152 | together with the token. 153 | 154 | Default is `Bearer`. 155 | 156 | ### JWT_EXPIRATION_DELTA 157 | 158 | This is an instance of Python's `datetime.timedelta`. This will be added to 159 | `datetime.utcnow()` to set the expiration time. 160 | 161 | Default is `datetime.timedelta(seconds=300)`(5 minutes). 162 | 163 | ### JWT_LEEWAY 164 | 165 | > This allows you to validate an expiration time which is in the past but no 166 | > very far. For example, if you have a JWT payload with an expiration time set 167 | > to 30 seconds after creation but you know that sometimes you will process it 168 | > after 30 seconds, you can set a leeway of 10 seconds in order to have some 169 | > margin. 170 | 171 | Default is `0` seconds. 172 | 173 | ### JWT_LOGIN_URLS 174 | 175 | Set the list of URLs that will be used to authenticate the user, you should take 176 | care to set only required URLs because the middleware will accept 177 | non-authenticated requests (no JWT) to these endpoints. 178 | 179 | ### JWT_PAYLOAD_GET_USER_ID_HANDLER 180 | 181 | If you store `user_id` differently than the default payload handler does, 182 | implement this function to fetch `user_id` from the payload. 183 | 184 | ### JWT_PAYLOAD_HANDLER 185 | 186 | Specify a custom function to generate the token payload 187 | 188 | ### JWT_REFRESH_EXPIRATION_DELTA 189 | 190 | Limit on token refresh, is a `datetime.timedelta` instance. This is how much 191 | time after the original token that future tokens can be refreshed from. 192 | 193 | Default is `datetime.timedelta(days=7)` (7 days). 194 | 195 | ### JWT_SECRET_KEY 196 | 197 | This is the secret key used to encrypt the JWT. Make sure this is safe and not 198 | shared or public. 199 | 200 | Default is your project's `settings.SECRET_KEY`. 201 | 202 | ### JWT_VERIFY 203 | 204 | If the secret is wrong, it will raise a jwt.DecodeError telling you as such. You 205 | can still get at the payload by setting the `JWT_VERIFY` to `False`. 206 | 207 | Default is `True`. 208 | 209 | ### JWT_VERIFY_EXPIRATION 210 | 211 | You can turn off expiration time verification with by setting 212 | `JWT_VERIFY_EXPIRATION` to `False`. 213 | 214 | Default is `True`. 215 | 216 | [pypi-version]: https://img.shields.io/pypi/v/webstack-django-jwt-auth.svg 217 | [pypi]: https://pypi.python.org/pypi/webstack-django-jwt-auth 218 | -------------------------------------------------------------------------------- /jwt_auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webstack/django-jwt-auth/d9165f439ebe258e027dd4bad6fa16d5afdb182e/jwt_auth/__init__.py -------------------------------------------------------------------------------- /jwt_auth/core.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | 3 | User = get_user_model() 4 | -------------------------------------------------------------------------------- /jwt_auth/exceptions.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext as _ 2 | 3 | 4 | class AuthenticationFailed(Exception): 5 | status_code = 401 6 | detail = _("Incorrect authentication credentials.") 7 | 8 | def __init__(self, detail=None): 9 | super().__init__(self) 10 | self.detail = detail or self.detail 11 | 12 | def __str__(self): 13 | return self.detail 14 | -------------------------------------------------------------------------------- /jwt_auth/forms.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from django import forms 4 | from django.contrib.auth import authenticate 5 | from django.contrib.auth.signals import user_logged_in 6 | from django.core.exceptions import ObjectDoesNotExist 7 | from django.utils.translation import gettext as _ 8 | from jwt_auth import settings as jwt_auth_settings 9 | from jwt_auth.core import User 10 | from jwt_auth.utils import jwt_get_user_id_from_payload_handler 11 | 12 | 13 | class JSONWebTokenForm(forms.Form): 14 | password = forms.CharField() 15 | 16 | def __init__(self, *args, **kwargs): 17 | super(JSONWebTokenForm, self).__init__(*args, **kwargs) 18 | 19 | # Dynamically add the USERNAME_FIELD to self.fields. 20 | self.fields[self.username_field] = forms.CharField() 21 | 22 | @property 23 | def username_field(self): 24 | try: 25 | return User.USERNAME_FIELD 26 | except AttributeError: 27 | return "username" 28 | 29 | def clean(self): 30 | cleaned_data = super(JSONWebTokenForm, self).clean() 31 | credentials = { 32 | self.username_field: cleaned_data.get(self.username_field), 33 | "password": cleaned_data.get("password"), 34 | } 35 | 36 | if not all(credentials.values()): 37 | raise forms.ValidationError(_("Must include 'username' and 'password'.")) 38 | 39 | user = authenticate(**credentials) 40 | 41 | if user: 42 | if not user.is_active: 43 | raise forms.ValidationError(_("User account is disabled.")) 44 | else: 45 | 46 | raise forms.ValidationError(_("Unable to login with provided credentials.")) 47 | 48 | cleaned_data["user"] = user 49 | user_logged_in.send(sender=user.__class__, request=None, user=user) 50 | 51 | 52 | class JSONWebTokenRefreshForm(forms.Form): 53 | token = forms.CharField() 54 | 55 | def clean(self): 56 | cleaned_data = super(JSONWebTokenRefreshForm, self).clean() 57 | 58 | old_payload = jwt_auth_settings.JWT_DECODE_HANDLER(cleaned_data.get("token")) 59 | user_id = jwt_get_user_id_from_payload_handler(old_payload) 60 | 61 | # Verify user 62 | try: 63 | user = User.objects.get(id=user_id) 64 | except ObjectDoesNotExist: 65 | raise forms.ValidationError(_("Unable to login with provided credentials.")) 66 | 67 | if not user.is_active: 68 | raise forms.ValidationError(_("User account is disabled.")) 69 | 70 | # Verify orig_iat 71 | orig_iat = old_payload.get("orig_iat") 72 | if not orig_iat: 73 | raise forms.ValidationError(_("orig_iat was missing from payload.")) 74 | 75 | # Verify expiration 76 | refresh_limit = jwt_auth_settings.JWT_REFRESH_EXPIRATION_DELTA 77 | 78 | if isinstance(refresh_limit, timedelta): 79 | refresh_limit = refresh_limit.days * 24 * 3600 + refresh_limit.seconds 80 | 81 | expiration_timestamp = orig_iat + int(refresh_limit) 82 | now_timestamp = datetime.utcnow().timestamp() 83 | 84 | if now_timestamp > expiration_timestamp: 85 | raise forms.ValidationError(_("Refresh has expired.")) 86 | 87 | # Data to re-issue new token. Include original issued at time for a 88 | # brand new token, to allow token refresh. 89 | cleaned_data["user"] = user 90 | cleaned_data["orig_iat"] = orig_iat 91 | -------------------------------------------------------------------------------- /jwt_auth/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # French translation of jwt_auth. 2 | # Copyright (C) 2018 Stéphane Raimbault 3 | # This file is distributed under the same license as the jwt_auth package. 4 | # Stéphane Raimbault , 2018 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: webstack-django-jwt-auth HEAD\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2018-12-17 23:18+0100\n" 11 | "PO-Revision-Date: 2018-12-17 23:10+0100\n" 12 | "Last-Translator: Stéphane Raimbault \n" 13 | "Language-Team: French\n" 14 | "Language: french\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 19 | 20 | #: exceptions.py:6 21 | msgid "Incorrect authentication credentials." 22 | msgstr "Identifiants d'authentification non valides." 23 | 24 | #: forms.py:46 forms.py:83 25 | msgid "User account is disabled." 26 | msgstr "Compte utilisateur désactivé." 27 | 28 | #: forms.py:58 forms.py:80 29 | msgid "Unable to login with provided credentials." 30 | msgstr "Impossible de s'identifier avec les identifiants fournis." 31 | 32 | #: forms.py:61 33 | msgid "Must include 'username' and 'password'." 34 | msgstr "Doit inclure « username » et « password »." 35 | 36 | #: forms.py:88 37 | msgid "orig_iat was missing from payload." 38 | msgstr "orig_iat n'est pas présent dans les données utiles." 39 | 40 | #: forms.py:100 41 | msgid "Refresh has expired." 42 | msgstr "L'actualisation a expiré." 43 | 44 | #: middleware.py:39 45 | msgid "Invalid user ID." 46 | msgstr "ID utilisation non valide." 47 | 48 | #: mixins.py:24 49 | msgid "Invalid Authorization header. No credentials provided." 50 | msgstr "L'entête Authorization n'est pas valide. Aucun identifiant fourni." 51 | 52 | #: mixins.py:29 53 | msgid "" 54 | "Invalid Authorization header. Credentials string should not contain spaces." 55 | msgstr "" 56 | "L'entête Authorization n'est pas valide. La chaine d'identifiant ne doit pas " 57 | "contenir d'espaces." 58 | 59 | #: mixins.py:41 60 | msgid "Signature has expired." 61 | msgstr "La signature a expirée." 62 | 63 | #: mixins.py:43 64 | msgid "Error decoding signature." 65 | msgstr "Erreur de décodage de la signature." 66 | 67 | #: mixins.py:51 68 | msgid "Invalid payload" 69 | msgstr "Données utiles non valides." 70 | 71 | #: views.py:15 72 | msgid "Improperly formatted request" 73 | msgstr "Requête non correctement formée." 74 | -------------------------------------------------------------------------------- /jwt_auth/middleware.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | from django.contrib.auth.models import AnonymousUser 5 | from django.contrib.auth.middleware import get_user 6 | from django.http import JsonResponse 7 | from django.utils.translation import gettext as _ 8 | from jwt_auth import settings as jwt_auth_settings, exceptions, mixins 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | jwt_decode_handler = jwt_auth_settings.JWT_DECODE_HANDLER 13 | jwt_get_user_id_from_payload = jwt_auth_settings.JWT_PAYLOAD_GET_USER_ID_HANDLER 14 | 15 | 16 | class JWTAuthenticationMiddleware: 17 | """ 18 | Token based authentication using the JSON Web Token standard. Clients should 19 | authenticate by passing the token key in the "Authorization" HTTP header, 20 | prepended with the string specified in the setting `JWT_AUTH_HEADER_PREFIX`. 21 | For example: 22 | 23 | Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdW.... 24 | 25 | Set user instance in the request, if the token is unset or invalid, 26 | request.user is set to an instance of AnonymousUser. 27 | """ 28 | 29 | def __init__(self, get_response): 30 | self.get_response = get_response 31 | 32 | def __call__(self, request): 33 | user = None 34 | if hasattr(request, 'session'): 35 | user = get_user(request) 36 | try: 37 | token = mixins.get_token_from_request(request) 38 | payload = mixins.get_payload_from_token(token) 39 | user_id = mixins.get_user_id_from_payload(payload) 40 | user = mixins.get_user(user_id) 41 | if not user: 42 | raise exceptions.AuthenticationFailed(_("Invalid user ID.")) 43 | except exceptions.AuthenticationFailed as e: 44 | logger.debug(e) 45 | 46 | request.user = user if user else AnonymousUser() 47 | return self.get_response(request) 48 | 49 | 50 | class RequiredJWTAuthenticationMiddleware: 51 | """ 52 | Token based authentication using the JSON Web Token standard. Clients should 53 | authenticate by passing the token key in the "Authorization" HTTP header, 54 | prepended with the string specified in the setting `JWT_AUTH_HEADER_PREFIX`. 55 | For example: 56 | 57 | Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdW.... 58 | 59 | Requests without the required JWT are not allowed (401), only requests on 60 | the login URL are allowed. 61 | """ 62 | 63 | def __init__(self, get_response): 64 | self.get_response = get_response 65 | 66 | def __call__(self, request): 67 | if request.path_info not in settings.JWT_LOGIN_URLS: 68 | try: 69 | token = mixins.get_token_from_request(request) 70 | payload = mixins.get_payload_from_token(token) 71 | user_id = mixins.get_user_id_from_payload(payload) 72 | request.user = mixins.get_user(user_id) 73 | if not request.user: 74 | raise exceptions.AuthenticationFailed(_("Invalid user ID.")) 75 | except exceptions.AuthenticationFailed as e: 76 | return JsonResponse({"error": str(e)}, status=401) 77 | 78 | return self.get_response(request) 79 | -------------------------------------------------------------------------------- /jwt_auth/mixins.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | from django.http import JsonResponse 3 | from django.utils.decorators import method_decorator 4 | from django.utils.translation import gettext as _ 5 | from django.views.decorators.csrf import csrf_exempt 6 | from jwt_auth import exceptions, settings 7 | from jwt_auth.core import User 8 | from jwt_auth.utils import get_authorization_header 9 | 10 | jwt_decode_handler = settings.JWT_DECODE_HANDLER 11 | jwt_get_user_id_from_payload = settings.JWT_PAYLOAD_GET_USER_ID_HANDLER 12 | 13 | 14 | def get_token_from_request(request): 15 | auth = get_authorization_header(request).split() 16 | auth_header_prefix = settings.JWT_AUTH_HEADER_PREFIX.lower() 17 | 18 | if not auth or auth[0].lower().decode("utf-8") != auth_header_prefix: 19 | raise exceptions.AuthenticationFailed() 20 | 21 | if len(auth) == 1: 22 | raise exceptions.AuthenticationFailed( 23 | _("Invalid Authorization header. No credentials provided.") 24 | ) 25 | elif len(auth) > 2: 26 | raise exceptions.AuthenticationFailed( 27 | _( 28 | "Invalid Authorization header. Credentials string " 29 | "should not contain spaces." 30 | ) 31 | ) 32 | 33 | return auth[1] 34 | 35 | 36 | def get_payload_from_token(token): 37 | try: 38 | payload = jwt_decode_handler(token) 39 | except jwt.ExpiredSignatureError: 40 | raise exceptions.AuthenticationFailed(_("Signature has expired.")) 41 | except jwt.DecodeError: 42 | raise exceptions.AuthenticationFailed(_("Error decoding signature.")) 43 | 44 | return payload 45 | 46 | 47 | def get_user_id_from_payload(payload): 48 | user_id = jwt_get_user_id_from_payload(payload) 49 | if not user_id: 50 | raise exceptions.AuthenticationFailed(_("Invalid payload")) 51 | 52 | return user_id 53 | 54 | 55 | def get_user(user_id): 56 | try: 57 | return User.objects.get(pk=user_id, is_active=True) 58 | except User.DoesNotExist: 59 | return None 60 | 61 | 62 | class JSONWebTokenAuthMixin: 63 | """ 64 | Token based authentication using the JSON Web Token standard. 65 | 66 | Clients should authenticate by passing the token key in the "Authorization" 67 | HTTP header, prepended with the string specified in the setting 68 | `JWT_AUTH_HEADER_PREFIX`. For example: 69 | 70 | Authorization: JWT eyJhbGciOiAiSFMyNTYiLCAidHlwIj 71 | """ 72 | 73 | www_authenticate_realm = "api" 74 | 75 | @method_decorator(csrf_exempt) 76 | def dispatch(self, request, *args, **kwargs): 77 | try: 78 | request.user, request.token = self.authenticate(request) 79 | except exceptions.AuthenticationFailed as error: 80 | response = JsonResponse({"errors": [str(error)]}, status=401) 81 | response["WWW-Authenticate"] = self.authenticate_header(request) 82 | 83 | return response 84 | 85 | return super(JSONWebTokenAuthMixin, self).dispatch(request, *args, **kwargs) 86 | 87 | def authenticate(self, request): # pylint: disable=no-self-use 88 | """Method required.""" 89 | token = get_token_from_request(request) 90 | payload = get_payload_from_token(token) 91 | user_id = get_user_id_from_payload(payload) 92 | return get_user(user_id), token 93 | 94 | def authenticate_header(self, request): 95 | """ 96 | Return a string to be used as the value of the `WWW-Authenticate` 97 | header in a `401 Unauthenticated` response, or `None` if the 98 | authentication scheme should return `403 Permission Denied` responses. 99 | """ 100 | return 'JWT realm="{0}"'.format(self.www_authenticate_realm) 101 | -------------------------------------------------------------------------------- /jwt_auth/settings.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import warnings 3 | 4 | from django.conf import settings 5 | from jwt_auth.utils import import_from_string 6 | 7 | JWT_ENCODE_HANDLER = import_from_string( 8 | getattr(settings, "JWT_ENCODE_HANDLER", "jwt_auth.utils.jwt_encode_handler") 9 | ) 10 | 11 | JWT_DECODE_HANDLER = import_from_string( 12 | getattr(settings, "JWT_DECODE_HANDLER", "jwt_auth.utils.jwt_decode_handler") 13 | ) 14 | 15 | JWT_PAYLOAD_HANDLER = import_from_string( 16 | getattr(settings, "JWT_PAYLOAD_HANDLER", "jwt_auth.utils.jwt_payload_handler") 17 | ) 18 | 19 | JWT_PAYLOAD_GET_USER_ID_HANDLER = import_from_string( 20 | getattr( 21 | settings, 22 | "JWT_PAYLOAD_GET_USER_ID_HANDLER", 23 | "jwt_auth.utils.jwt_get_user_id_from_payload_handler", 24 | ) 25 | ) 26 | 27 | JWT_SECRET_KEY = getattr(settings, "JWT_SECRET_KEY", settings.SECRET_KEY) 28 | 29 | JWT_ALGORITHM = getattr(settings, "JWT_ALGORITHM", "HS256") 30 | 31 | JWT_VERIFY = getattr(settings, "JWT_VERIFY", True) 32 | 33 | JWT_VERIFY_EXPIRATION = getattr(settings, "JWT_VERIFY_EXPIRATION", True) 34 | 35 | JWT_LEEWAY = getattr(settings, "JWT_LEEWAY", 0) 36 | 37 | JWT_EXPIRATION_DELTA = getattr( 38 | settings, "JWT_EXPIRATION_DELTA", datetime.timedelta(seconds=300) 39 | ) 40 | 41 | JWT_ALLOW_REFRESH = getattr(settings, "JWT_ALLOW_REFRESH", False) 42 | 43 | JWT_REFRESH_EXPIRATION_DELTA = getattr( 44 | settings, "JWT_REFRESH_EXPIRATION_DELTA", datetime.timedelta(seconds=300) 45 | ) 46 | 47 | JWT_AUTH_HEADER_PREFIX = getattr(settings, "JWT_AUTH_HEADER_PREFIX", "Bearer") 48 | 49 | JWT_AUDIENCE = getattr(settings, "JWT_AUDIENCE", None) 50 | 51 | if getattr(settings, "JWT_LOGIN_URL", None): 52 | warnings.warn("'JWT_LOGIN_URL' has been replaced by 'JWT_LOGIN_URLS'") 53 | 54 | JWT_LOGIN_URLS = getattr(settings, "JWT_LOGIN_URLS", [settings.LOGIN_URL]) 55 | -------------------------------------------------------------------------------- /jwt_auth/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import importlib 3 | 4 | import jwt 5 | 6 | 7 | def jwt_payload_handler(user): 8 | from jwt_auth import settings 9 | 10 | try: 11 | username = user.get_username() 12 | except AttributeError: 13 | username = user.username 14 | 15 | return { 16 | "user_id": user.pk, 17 | "email": user.email, 18 | "username": username, 19 | "exp": datetime.utcnow() + settings.JWT_EXPIRATION_DELTA, 20 | } 21 | 22 | 23 | def jwt_get_user_id_from_payload_handler(payload): 24 | """ 25 | Override this function if user_id is formatted differently in payload 26 | """ 27 | user_id = payload.get("user_id") 28 | return user_id 29 | 30 | 31 | def jwt_encode_handler(payload): 32 | from jwt_auth import settings 33 | 34 | return jwt.encode(payload, settings.JWT_SECRET_KEY, settings.JWT_ALGORITHM) 35 | 36 | 37 | def jwt_decode_handler(token): 38 | from jwt_auth import settings 39 | 40 | options = { 41 | "verify_exp": settings.JWT_VERIFY_EXPIRATION, 42 | "verify": settings.JWT_VERIFY, 43 | } 44 | 45 | return jwt.decode( 46 | jwt=token, 47 | key=settings.JWT_SECRET_KEY, 48 | algorithms=["HS256"], 49 | options=options, 50 | leeway=settings.JWT_LEEWAY, 51 | audience=settings.JWT_AUDIENCE, 52 | ) 53 | 54 | 55 | def import_from_string(val): 56 | """ 57 | Attempt to import a class from a string representation. 58 | 59 | From: https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/settings.py 60 | """ 61 | try: 62 | # Nod to tastypie's use of importlib. 63 | parts = val.split(".") 64 | module_path, class_name = ".".join(parts[:-1]), parts[-1] 65 | module = importlib.import_module(module_path) 66 | return getattr(module, class_name) 67 | except ImportError as error: 68 | msg = "Could not import '%s' for setting. %s: %s." % ( 69 | val, 70 | error.__class__.__name__, 71 | error, 72 | ) 73 | raise ImportError(msg) 74 | 75 | 76 | def get_authorization_header(request): 77 | """ 78 | Return request's 'Authorization:' header, as a bytestring. 79 | From: https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/authentication.py 80 | """ 81 | auth = request.META.get("HTTP_AUTHORIZATION", b"") 82 | 83 | if isinstance(auth, type("")): 84 | # Work around django test client oddness 85 | auth = auth.encode("iso-8859-1") 86 | 87 | return auth 88 | -------------------------------------------------------------------------------- /jwt_auth/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | 4 | from django.http import JsonResponse 5 | from django.utils.decorators import method_decorator 6 | from django.utils.translation import gettext as _ 7 | from django.views.decorators.csrf import csrf_exempt 8 | from django.views.generic import View 9 | from jwt_auth import settings as jwt_auth_settings 10 | from jwt_auth.forms import JSONWebTokenForm, JSONWebTokenRefreshForm 11 | 12 | 13 | def jwt_encode_token(user, orig_iat=None): 14 | payload = jwt_auth_settings.JWT_PAYLOAD_HANDLER(user) 15 | 16 | if orig_iat is None: 17 | if jwt_auth_settings.JWT_ALLOW_REFRESH: 18 | # Include original issued at time for a brand new token, to 19 | # allow token refresh 20 | payload["orig_iat"] = int(datetime.utcnow().timestamp()) 21 | else: 22 | payload["orig_iat"] = orig_iat 23 | 24 | return jwt_auth_settings.JWT_ENCODE_HANDLER(payload) 25 | 26 | 27 | def jwt_get_json_with_token(token): 28 | return { 29 | "token_type": jwt_auth_settings.JWT_AUTH_HEADER_PREFIX, 30 | "token": token, 31 | "expires_in": jwt_auth_settings.JWT_EXPIRATION_DELTA.total_seconds(), 32 | } 33 | 34 | 35 | class JSONWebTokenViewBase(View): 36 | http_method_names = ["post"] 37 | 38 | @method_decorator(csrf_exempt) 39 | def dispatch(self, request, *args, **kwargs): 40 | return super(JSONWebTokenViewBase, self).dispatch(request, *args, **kwargs) 41 | 42 | def get_form(self, request_json): 43 | raise NotImplementedError() 44 | 45 | def post(self, request): 46 | try: 47 | request_json = json.loads(request.body.decode("utf-8")) 48 | except ValueError: 49 | return JsonResponse( 50 | {"errors": [_("Improperly formatted request")]}, status=400 51 | ) 52 | 53 | form = self.get_form(request_json) 54 | if not form.is_valid(): 55 | return JsonResponse({"errors": form.errors}, status=400) 56 | 57 | token = jwt_encode_token( 58 | form.cleaned_data["user"], form.cleaned_data.get("orig_iat") 59 | ) 60 | return JsonResponse(jwt_get_json_with_token(token)) 61 | 62 | 63 | class JSONWebToken(JSONWebTokenViewBase): 64 | def get_form(self, request_json): 65 | return JSONWebTokenForm(request_json) 66 | 67 | 68 | class RefreshJSONWebToken(JSONWebTokenViewBase): 69 | def get_form(self, request_json): 70 | return JSONWebTokenRefreshForm(request_json) 71 | 72 | 73 | jwt_token = JSONWebToken.as_view() 74 | refresh_jwt_token = RefreshJSONWebToken.as_view() 75 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: # pragma: no cover 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | black 2 | bumpversion 3 | coverage 4 | pylint 5 | twine 6 | wheel 7 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 6 | # Make sure the app is (at least temporarily) on the import path. 7 | APP_DIR = os.path.abspath(os.path.dirname(__file__)) 8 | sys.path.insert(0, APP_DIR) 9 | 10 | 11 | def run_tests(): 12 | 13 | # Making Django run this way is a two-step process. First, call 14 | # settings.configure() to give Django settings to work with: 15 | from django.conf import settings 16 | 17 | # Then, call django.setup() to initialize the application registry 18 | # and other bits: 19 | import django 20 | 21 | django.setup() 22 | 23 | # Now we instantiate a test runner... 24 | from django.test.utils import get_runner 25 | 26 | TestRunner = get_runner(settings) 27 | 28 | # And then we run tests and return the results. 29 | test_runner = TestRunner(verbosity=2, interactive=True) 30 | failures = test_runner.run_tests(["tests"]) 31 | sys.exit(failures) 32 | 33 | 34 | if __name__ == "__main__": 35 | run_tests() 36 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | 5 | from setuptools import setup 6 | 7 | if sys.argv[-1] == "publish": 8 | print( 9 | """ 10 | 1. bumpversion minor (or patch, major) 11 | 2. python setup.py sdist bdist_wheel 12 | 3. twine upload dist/* 13 | """ 14 | ) 15 | sys.exit() 16 | 17 | 18 | def get_long_description(): 19 | """ 20 | Return the README. 21 | """ 22 | return open("README.md", "r", encoding="utf8").read() 23 | 24 | 25 | setup( 26 | name="webstack-django-jwt-auth", 27 | version="1.4.0", 28 | url="https://github.com/webstack/django-jwt-auth", 29 | license="MIT", 30 | description="JSON Web Token based authentication for Django", 31 | long_description=get_long_description(), 32 | long_description_content_type="text/markdown", 33 | # Original author is "Jose Padilla " 34 | author="Stéphane Raimbault", 35 | author_email="stephane.raimbault@webstack.fr", 36 | packages=["jwt_auth"], 37 | test_suite="runtests.run_tests", 38 | classifiers=[ 39 | "Development Status :: 5 - Production/Stable", 40 | "Environment :: Web Environment", 41 | "Framework :: Django", 42 | "Intended Audience :: Developers", 43 | "License :: OSI Approved :: MIT License", 44 | "Operating System :: OS Independent", 45 | "Programming Language :: Python", 46 | "Programming Language :: Python :: 3", 47 | "Topic :: Internet :: WWW/HTTP", 48 | ], 49 | install_requires=["Django>=3.0", "PyJWT>=2.0.0"], 50 | ) 51 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webstack/django-jwt-auth/d9165f439ebe258e027dd4bad6fa16d5afdb182e/tests/__init__.py -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | DEBUG = False 2 | 3 | TIME_ZONE = "UTC" 4 | LANGUAGE_CODE = "en-US" 5 | USE_L10N = True 6 | USE_TZ = True 7 | 8 | SECRET_KEY = "dont-tell-eve" 9 | 10 | ROOT_URLCONF = "tests.urls" 11 | 12 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 13 | 14 | INSTALLED_APPS = ( 15 | "django.contrib.auth", 16 | "django.contrib.contenttypes", 17 | "django.contrib.sessions", 18 | "django.contrib.messages", 19 | "django.contrib.staticfiles", 20 | "django.contrib.sites", 21 | "tests", 22 | ) 23 | 24 | PASSWORD_HASHERS = ("django.contrib.auth.hashers.MD5PasswordHasher",) 25 | 26 | JWT_LOGIN_URLS = ["/token-auth/"] 27 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import reverse 2 | from django.test import TestCase, modify_settings 3 | from django.test.client import Client 4 | from jwt_auth.core import User 5 | 6 | 7 | class MiddlewareTestCase(TestCase): 8 | def setUp(self): 9 | self.email = "foo@example.com" 10 | self.username = "foo" 11 | self.password = "password" 12 | self.user = User.objects.create_user(self.username, self.email, self.password) 13 | self.data = {"username": self.username, "password": self.password} 14 | 15 | self.client = Client() 16 | self.auth_token_url = reverse("auth_token") 17 | self.protected_url = reverse("protected") 18 | self.plain_url = reverse("plain") 19 | 20 | def get_auth_header(self, response): 21 | return "Bearer {0}".format(response.json()["token"]) 22 | 23 | 24 | class NoMiddleWareTestCase(MiddlewareTestCase): 25 | def test_access_allowed(self): 26 | response = self.client.get(self.plain_url) 27 | self.assertEqual(response.status_code, 200) 28 | self.assertEqual(response.json()["username"], None) 29 | 30 | def test_access_denied_to_protected(self): 31 | response = self.client.get(self.protected_url) 32 | self.assertEqual(response.status_code, 401) 33 | 34 | 35 | class JWTAuthenticationMiddlewareTestCase(MiddlewareTestCase): 36 | @modify_settings( 37 | MIDDLEWARE={"append": "jwt_auth.middleware.JWTAuthenticationMiddleware"} 38 | ) 39 | def test_anonymous(self): 40 | response = self.client.get(self.plain_url) 41 | self.assertEqual(response.status_code, 200) 42 | self.assertEqual(response.json()["username"], "") 43 | 44 | @modify_settings( 45 | MIDDLEWARE={"append": "jwt_auth.middleware.JWTAuthenticationMiddleware"} 46 | ) 47 | def test_authenticated(self): 48 | response = self.client.post( 49 | self.auth_token_url, self.data, content_type="application/json" 50 | ) 51 | self.assertEqual(response.status_code, 200) 52 | header_value = self.get_auth_header(response) 53 | response = self.client.get( 54 | self.plain_url, 55 | content_type="application/json", 56 | HTTP_AUTHORIZATION=header_value, 57 | ) 58 | self.assertEqual(response.json()["username"], "foo") 59 | 60 | 61 | class RequiredJWTAuthenticationMiddlewareTestCase(MiddlewareTestCase): 62 | @modify_settings( 63 | MIDDLEWARE={"append": "jwt_auth.middleware.RequiredJWTAuthenticationMiddleware"} 64 | ) 65 | def test_access_denied(self): 66 | response = self.client.get(self.plain_url) 67 | self.assertEqual(response.status_code, 401) 68 | 69 | response = self.client.get(self.protected_url) 70 | self.assertEqual(response.status_code, 401) 71 | 72 | @modify_settings( 73 | MIDDLEWARE={"append": "jwt_auth.middleware.RequiredJWTAuthenticationMiddleware"} 74 | ) 75 | def test_access_allowed(self): 76 | response = self.client.post( 77 | self.auth_token_url, self.data, content_type="application/json" 78 | ) 79 | self.assertEqual(response.status_code, 200) 80 | header_value = self.get_auth_header(response) 81 | response = self.client.get( 82 | self.plain_url, 83 | content_type="application/json", 84 | HTTP_AUTHORIZATION=header_value, 85 | ) 86 | self.assertEqual(response.status_code, 200) 87 | -------------------------------------------------------------------------------- /tests/test_mixins.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import reverse 2 | from django.test import TestCase 3 | from django.test.client import Client 4 | from jwt_auth import utils 5 | from jwt_auth.core import User 6 | 7 | 8 | class JSONWebTokenAuthMixinTestCase(TestCase): 9 | def setUp(self): 10 | self.email = "foo@example.com" 11 | self.username = "foo" 12 | self.password = "password" 13 | self.user = User.objects.create_user(self.username, self.email, self.password) 14 | self.data = {"username": self.username, "password": self.password} 15 | 16 | self.client = Client() 17 | self.protected_url = reverse("protected") 18 | 19 | def test_passing_jwt_auth(self): 20 | """ 21 | Ensure getting form over JWT auth with correct credentials passes and 22 | does not require CSRF 23 | """ 24 | payload = utils.jwt_payload_handler(self.user) 25 | token = utils.jwt_encode_handler(payload) 26 | 27 | auth = "Bearer {0}".format(token) 28 | response = self.client.get( 29 | self.protected_url, content_type="application/json", HTTP_AUTHORIZATION=auth 30 | ) 31 | 32 | self.assertEqual(response.status_code, 200) 33 | self.assertEqual(response.json()["username"], self.username) 34 | 35 | def test_failing_jwt_auth(self): 36 | """ 37 | Ensure POSTing json over JWT auth without correct credentials fails 38 | """ 39 | response = self.client.get(self.protected_url, content_type="application/json") 40 | 41 | self.assertEqual(response.status_code, 401) 42 | self.assertEqual(response["WWW-Authenticate"], 'JWT realm="api"') 43 | expected_error = ["Incorrect authentication credentials."] 44 | self.assertEqual(response.json()["errors"], expected_error) 45 | 46 | def test_no_jwt_header_failing_jwt_auth(self): 47 | """ 48 | Ensure POSTing over JWT auth without credentials fails 49 | """ 50 | auth = "Bearer" 51 | response = self.client.get( 52 | self.protected_url, content_type="application/json", HTTP_AUTHORIZATION=auth 53 | ) 54 | self.assertEqual(response.status_code, 401) 55 | self.assertEqual(response["WWW-Authenticate"], 'JWT realm="api"') 56 | 57 | expected_error = ["Invalid Authorization header. No credentials provided."] 58 | self.assertEqual(response.json()["errors"], expected_error) 59 | 60 | def test_invalid_jwt_header_failing_jwt_auth(self): 61 | """ 62 | Ensure getting over JWT auth without correct credentials fails 63 | """ 64 | auth = "Bearer abc abc" 65 | response = self.client.post( 66 | self.protected_url, content_type="application/json", HTTP_AUTHORIZATION=auth 67 | ) 68 | self.assertEqual(response.status_code, 401) 69 | self.assertEqual(response["WWW-Authenticate"], 'JWT realm="api"') 70 | expected_error = [ 71 | "Invalid Authorization header. Credentials string should not contain spaces." 72 | ] 73 | self.assertEqual(response.json()["errors"], expected_error) 74 | 75 | def test_expired_token_failing_jwt_auth(self): 76 | """ 77 | Ensure getting over JWT auth with expired token fails 78 | """ 79 | payload = utils.jwt_payload_handler(self.user) 80 | payload["exp"] = 1 81 | token = utils.jwt_encode_handler(payload) 82 | 83 | auth = "Bearer {0}".format(token) 84 | response = self.client.get( 85 | self.protected_url, content_type="application/json", HTTP_AUTHORIZATION=auth 86 | ) 87 | self.assertEqual(response.status_code, 401) 88 | self.assertEqual(response["WWW-Authenticate"], 'JWT realm="api"') 89 | expected_error = ["Signature has expired."] 90 | self.assertEqual(response.json()["errors"], expected_error) 91 | 92 | def test_invalid_token_failing_jwt_auth(self): 93 | """ 94 | Ensure POSTing over JWT auth with invalid token fails 95 | """ 96 | auth = "Bearer abc123" 97 | response = self.client.get( 98 | self.protected_url, content_type="application/json", HTTP_AUTHORIZATION=auth 99 | ) 100 | self.assertEqual(response.status_code, 401) 101 | self.assertEqual(response["WWW-Authenticate"], 'JWT realm="api"') 102 | 103 | expected_error = ["Error decoding signature."] 104 | self.assertEqual(response.json()["errors"], expected_error) 105 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from calendar import timegm 2 | from datetime import datetime, timedelta 3 | 4 | from django.shortcuts import reverse 5 | from django.test import TestCase 6 | from django.test.client import Client 7 | from jwt_auth import settings, utils 8 | from jwt_auth.core import User 9 | 10 | 11 | class JSONWebTokenTestCase(TestCase): 12 | def setUp(self): 13 | self.email = "foo@example.com" 14 | self.username = "foo" 15 | self.password = "password" 16 | self.user = User.objects.create_user(self.username, self.email, self.password) 17 | 18 | self.data = {"username": self.username, "password": self.password} 19 | 20 | self.client = Client() 21 | self.auth_token_url = reverse("auth_token") 22 | 23 | def test_login(self): 24 | """ 25 | Ensure JWT login view using JSON POST works. 26 | """ 27 | response = self.client.post( 28 | self.auth_token_url, self.data, content_type="application/json" 29 | ) 30 | response_content = response.json() 31 | expires_in = response_content["expires_in"] 32 | self.assertEqual(expires_in, settings.JWT_EXPIRATION_DELTA.total_seconds()) 33 | decoded_payload = utils.jwt_decode_handler(response_content["token"]) 34 | 35 | self.assertEqual(response.status_code, 200) 36 | self.assertEqual(decoded_payload["username"], self.username) 37 | 38 | def test_bad_credentials(self): 39 | """ 40 | Ensure JWT login view using JSON POST fails if bad credentials are used. 41 | """ 42 | self.data["password"] = "wrong" 43 | 44 | response = self.client.post( 45 | self.auth_token_url, self.data, content_type="application/json" 46 | ) 47 | self.assertEqual(response.status_code, 400) 48 | 49 | def test_missing_fields(self): 50 | """ 51 | Ensure JWT login view using JSON POST fails if missing fields. 52 | """ 53 | response = self.client.post( 54 | self.auth_token_url, 55 | {"username": self.username}, 56 | content_type="application/json", 57 | ) 58 | self.assertEqual(response.status_code, 400) 59 | 60 | def test_login_with_expired_token(self): 61 | """ 62 | Ensure JWT login view works even if expired token is provided 63 | """ 64 | payload = utils.jwt_payload_handler(self.user) 65 | payload["exp"] = 1 66 | token = utils.jwt_encode_handler(payload) 67 | 68 | auth = "Bearer {0}".format(token) 69 | 70 | response = self.client.post( 71 | self.auth_token_url, 72 | self.data, 73 | content_type="application/json", 74 | HTTP_AUTHORIZATION=auth, 75 | ) 76 | response_content = response.json() 77 | decoded_payload = utils.jwt_decode_handler(response_content["token"]) 78 | 79 | self.assertEqual(response.status_code, 200) 80 | self.assertEqual(decoded_payload["username"], self.username) 81 | 82 | def test_invalid_payload(self): 83 | # Not JSON content 84 | response = self.client.post(self.auth_token_url, {"foo": "bar"}) 85 | self.assertEqual(response.status_code, 400) 86 | 87 | 88 | class RefreshJSONWebTokenTestCase(TestCase): 89 | def setUp(self): 90 | self.email = "jpueblo@example.com" 91 | self.username = "jpueblo" 92 | self.password = "password" 93 | self.user = User.objects.create_user(self.username, self.email, self.password) 94 | 95 | self.payload = utils.jwt_payload_handler(self.user) 96 | self.payload["orig_iat"] = timegm(datetime.utcnow().utctimetuple()) 97 | 98 | self.client = Client() 99 | self.refresh_auth_token_url = reverse("refresh_token") 100 | 101 | def test_refresh(self): 102 | """ 103 | Ensure JWT refresh view using JSON POST works. 104 | """ 105 | data = {"token": utils.jwt_encode_handler(self.payload)} 106 | 107 | response = self.client.post( 108 | self.refresh_auth_token_url, data, content_type="application/json" 109 | ) 110 | decoded_payload = utils.jwt_decode_handler(response.json()["token"]) 111 | 112 | self.assertEqual(response.status_code, 200) 113 | self.assertEqual(decoded_payload["username"], self.username) 114 | 115 | def test_inactive_user(self): 116 | """ 117 | Ensure JWT refresh view using JSON POST fails 118 | if the user is inactive 119 | """ 120 | 121 | self.user.is_active = False 122 | self.user.save() 123 | 124 | data = {"token": utils.jwt_encode_handler(self.payload)} 125 | response = self.client.post( 126 | self.refresh_auth_token_url, data, content_type="application/json" 127 | ) 128 | 129 | self.assertEqual(response.status_code, 400) 130 | 131 | def test_no_orig_iat(self): 132 | """ 133 | Ensure JWT refresh view using JSON POST fails 134 | if no orig_iat is present on the payload. 135 | """ 136 | self.payload.pop("orig_iat") 137 | 138 | data = {"token": utils.jwt_encode_handler(self.payload)} 139 | response = self.client.post( 140 | self.refresh_auth_token_url, data, content_type="application/json" 141 | ) 142 | 143 | self.assertEqual(response.status_code, 400) 144 | 145 | def test_refresh_with_expired_token(self): 146 | """ 147 | Ensure JWT refresh view using JSON POST fails 148 | if the refresh has expired 149 | """ 150 | 151 | # We make sure that the refresh token is not in the window 152 | # allowed by the expiration delta. This is much easier using 153 | # freezegun. 154 | orig_iat = ( 155 | datetime.utcfromtimestamp(self.payload["orig_iat"]) 156 | - settings.JWT_REFRESH_EXPIRATION_DELTA 157 | - timedelta(days=1) 158 | ) 159 | self.payload["orig_iat"] = timegm(orig_iat.utctimetuple()) 160 | data = {"token": utils.jwt_encode_handler(self.payload)} 161 | response = self.client.post( 162 | self.refresh_auth_token_url, data, content_type="application/json" 163 | ) 164 | 165 | self.assertEqual(response.status_code, 400) 166 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from jwt_auth import views as jwt_auth_views 3 | 4 | from tests import views 5 | 6 | urlpatterns = [ 7 | path("plain/", views.plain_view, name="plain"), 8 | path("protected/", views.ProtectedView.as_view(), name="protected"), 9 | path("token-auth/", jwt_auth_views.jwt_token, name="auth_token"), 10 | path("token-refresh/", jwt_auth_views.refresh_jwt_token, name="refresh_token"), 11 | ] 12 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from django.http import JsonResponse 2 | from django.views.generic import View 3 | from jwt_auth.mixins import JSONWebTokenAuthMixin 4 | 5 | 6 | class ProtectedView(JSONWebTokenAuthMixin, View): 7 | def get(self, request): 8 | return JsonResponse({"username": request.user.username}) 9 | 10 | 11 | def plain_view(request): 12 | return JsonResponse( 13 | {"username": request.user.username if getattr(request, "user", None) else None} 14 | ) 15 | --------------------------------------------------------------------------------