├── .github └── workflows │ ├── python-package.yml │ ├── python-publish.yml │ └── sonarcloud.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── jwt_knox ├── __init__.py ├── apps.py ├── auth.py ├── migrations │ └── __init__.py ├── settings.py ├── urls.py ├── utils.py └── viewsets.py ├── poetry.lock ├── pyproject.toml ├── requirements ├── requirements-django32lts.txt ├── requirements-django41.txt ├── requirements-django42lts.txt └── requirements-testing.txt ├── runtests.py ├── setup.cfg ├── setup.py ├── sonar-project.properties ├── tests ├── __init__.py ├── conftest.py ├── test_jwt_knox.py └── urls.py └── tox.ini /.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: Python package 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.9", "3.10"] 20 | django-version: ["32lts", "41", "42lts"] 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip poetry-dynamic-versioning poetry 31 | poetry install 32 | poetry run python -m pip install -r requirements/requirements-testing.txt 33 | poetry run python -m pip install -r requirements/requirements-django${{ matrix.django-version }}.txt 34 | - name: Lint with flake8 35 | run: | 36 | # stop the build if there are Python syntax errors or undefined names 37 | poetry run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 38 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 39 | poetry run flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 40 | - name: Test with pytest 41 | run: | 42 | poetry run pytest --cov 43 | -------------------------------------------------------------------------------- /.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 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | jobs: 16 | deploy: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: '3.x' 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install poetry-dynamic-versioning poetry 30 | - name: Build package 31 | run: poetry build 32 | - name: Publish package 33 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 34 | with: 35 | user: __token__ 36 | password: ${{ secrets.PYPI_API_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/sonarcloud.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # This workflow helps you trigger a SonarCloud analysis of your code and populates 7 | # GitHub Code Scanning alerts with the vulnerabilities found. 8 | # Free for open source project. 9 | 10 | # 1. Login to SonarCloud.io using your GitHub account 11 | 12 | # 2. Import your project on SonarCloud 13 | # * Add your GitHub organization first, then add your repository as a new project. 14 | # * Please note that many languages are eligible for automatic analysis, 15 | # which means that the analysis will start automatically without the need to set up GitHub Actions. 16 | # * This behavior can be changed in Administration > Analysis Method. 17 | # 18 | # 3. Follow the SonarCloud in-product tutorial 19 | # * a. Copy/paste the Project Key and the Organization Key into the args parameter below 20 | # (You'll find this information in SonarCloud. Click on "Information" at the bottom left) 21 | # 22 | # * b. Generate a new token and add it to your Github repository's secrets using the name SONAR_TOKEN 23 | # (On SonarCloud, click on your avatar on top-right > My account > Security 24 | # or go directly to https://sonarcloud.io/account/security/) 25 | 26 | # Feel free to take a look at our documentation (https://docs.sonarcloud.io/getting-started/github/) 27 | # or reach out to our community forum if you need some help (https://community.sonarsource.com/c/help/sc/9) 28 | 29 | name: SonarCloud analysis 30 | 31 | on: 32 | push: 33 | branches: [ "main" ] 34 | pull_request: 35 | branches: [ "main" ] 36 | workflow_dispatch: 37 | 38 | permissions: 39 | pull-requests: read # allows SonarCloud to decorate PRs with analysis results 40 | 41 | jobs: 42 | Analysis: 43 | runs-on: ubuntu-latest 44 | 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v3.5.2 48 | with: 49 | fetch-depth: 0 # optional, default is 1 50 | - name: Run pytest with coverage 51 | run: | 52 | python -m pip install --upgrade pip poetry-dynamic-versioning poetry 53 | poetry install 54 | poetry run python -m pip install -r requirements/requirements-testing.txt 55 | poetry run python -m pip install -r requirements/requirements-django42lts.txt 56 | poetry run pytest --cov-report xml --cov 57 | 58 | - name: Analyze with SonarCloud 59 | 60 | # You can pin the exact commit or the version. 61 | # uses: SonarSource/sonarcloud-github-action@de2e56b42aa84d0b1c5b622644ac17e505c9a049 62 | uses: SonarSource/sonarcloud-github-action@de2e56b42aa84d0b1c5b622644ac17e505c9a049 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information 65 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} # Generate a token on Sonarcloud.io, add it to the secrets of this repo with the name SONAR_TOKEN (Settings > Secrets > Actions > add new repository secret) 66 | with: 67 | # Additional arguments for the sonarcloud scanner 68 | args: 69 | # Unique keys of your project and organization. You can find them in SonarCloud > Information (bottom-left menu) 70 | # mandatory 71 | -Dsonar.projectKey=ssaavedra_drf-jwt-knox 72 | -Dsonar.organization=ssaavedra-github 73 | # Comma-separated paths to directories containing main source files. 74 | #-Dsonar.sources= # optional, default is project base directory 75 | # When you need the analysis to take place in a directory other than the one from which it was launched 76 | #-Dsonar.projectBaseDir= # optional, default is . 77 | # Comma-separated paths to directories containing test source files. 78 | #-Dsonar.tests= # optional. For more info about Code Coverage, please refer to https://docs.sonarcloud.io/enriching/test-coverage/overview/ 79 | # Adds more detail to both client and server-side analysis logs, activating DEBUG mode for the scanner, and adding client-side environment variables and system properties to the server-side log of analysis report processing. 80 | #-Dsonar.verbose= # optional, default is false 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Back-up test files 2 | *~ 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # IPython Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # dotenv 82 | .env 83 | 84 | # virtualenv 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | 91 | # Rope project settings 92 | .ropeproject 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Include the license file 2 | include LICENSE 3 | include README.md 4 | 5 | recursive-exclude * __pycache__ 6 | recursive-exclude * *.py[co] 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | DRF JWT + Knox 2 | ============== 3 | 4 | [![Build status](https://github.com/ssaavedra/drf-jwt-knox/actions/workflows/python-package.yml/badge.svg)](https://github.com/ssaavedra/drf-jwt-knox/actions/workflows/python-package.yml) 5 | [![coverage](https://img.shields.io/sonar/coverage/ssaavedra_drf-jwt-knox/main?server=https%3A%2F%2Fsonarcloud.io)](https://sonarcloud.io/dashboard?id=ssaavedra_drf-jwt-knox) 6 | [![PyPI version](https://img.shields.io/pypi/v/drf-jwt-knox.svg)](https://pypi.python.org/pypi/drf-jwt-knox) 7 | [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=ssaavedra_drf-jwt-knox&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=ssaavedra_drf-jwt-knox) 8 | 9 | 10 | 11 | This package provides an authentication mechanism for Django REST 12 | Framework based on [JSON Web Tokens][JWT] in the browser backed up by 13 | [Knox][knox]-powered tokens in the database. 14 | 15 | This package aims to take the better parts of both worlds, including: 16 | 17 | - Expirable tokens: The tokens may be manually expired in the 18 | database, so a user can log out of all other logged-in places, or 19 | everywhere. 20 | - Different tokens per login attempt (per user-agent), meaning that a 21 | user's session is tied to the specific machine and logging can be 22 | segregated per usage. 23 | - JWT-based tokens, so the token can have an embedded expiration time, 24 | and further metadata for other applications. 25 | - Tokens are generated via OpenSSL so that they are cryptographically more secure. 26 | - Only the tokens' hashes are stored in the database, so that even if 27 | the database gets dumped, an attacker cannot impersonate people 28 | through existing credentials 29 | - Other applications sharing the JWT private key can also decrypt the JWT 30 | 31 | 32 | Usage 33 | ===== 34 | 35 | Add this application **and knox** to `INSTALLED_APPS` in your 36 | `settings.py`. 37 | 38 | Then, add this app's routes to some of your `urlpatterns`. 39 | 40 | You can use the `verify` endpoint to verify whether a token is valid 41 | or not (which may be useful in a microservice architecture). 42 | 43 | 44 | Tests 45 | ===== 46 | 47 | Tests are automated with `tox` and run on Travis-CI automatically. You 48 | can check the status in Travis, or just run `tox` from the command 49 | line. 50 | 51 | 52 | Contributing 53 | ============ 54 | 55 | This project uses the GitHub Flow approach for contributing, meaning 56 | that we would really appreciate it if you would send patches as Pull 57 | Requests in GitHub. If for any reason you prefer to send patches by email, they are also welcome and will end up being integrated here. 58 | 59 | License 60 | ======= 61 | 62 | This code is released under the Apache Software License Version 2.0. 63 | 64 | 65 | [JWT]: https://github.com/jpadilla/pyjwt 66 | [knox]: https://github.com/James1345/django-rest-knox 67 | -------------------------------------------------------------------------------- /jwt_knox/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ssaavedra/drf-jwt-knox/1017786ce7d9044fb613c845cf66393cdf585aad/jwt_knox/__init__.py -------------------------------------------------------------------------------- /jwt_knox/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class JwtKnoxConfig(AppConfig): 5 | name = 'jwt_knox' 6 | -------------------------------------------------------------------------------- /jwt_knox/auth.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | from django.contrib.auth import get_user_model 3 | from django.utils import timezone 4 | from django.utils.encoding import smart_str 5 | from django.utils.translation import gettext as _ 6 | from knox.crypto import hash_token 7 | from knox.models import AuthToken 8 | from rest_framework import exceptions 9 | from rest_framework.authentication import (BaseAuthentication, 10 | get_authorization_header) 11 | 12 | from jwt_knox.settings import api_settings 13 | 14 | jwt_decode_handler = api_settings.JWT_DECODE_HANDLER 15 | jwt_get_username_from_payload = api_settings.JWT_PAYLOAD_GET_USERNAME_HANDLER 16 | jwt_get_knox_token_from_payload = api_settings.JWT_PAYLOAD_GET_TOKEN_HANDLER 17 | 18 | 19 | class BaseJWTTAuthentication(BaseAuthentication): 20 | """ 21 | Token based authentication using Knox and JSON Web Token standard. 22 | """ 23 | 24 | def authenticate(self, request): 25 | """ 26 | Returns a two-tuple of `User` and token if a valid signature is 27 | supplied and the underlying token exists on the database. Otherwise 28 | returns None. 29 | """ 30 | decoded_token = self.get_jwt_value(request) 31 | if decoded_token is None: 32 | return None 33 | 34 | (user, auth_token) = self.authenticate_credentials(decoded_token) 35 | 36 | return (user, (decoded_token, auth_token)) 37 | 38 | def authenticate_credentials(self, payload): 39 | """ 40 | Returns an active user that matches the payload's user id and token. 41 | """ 42 | User = get_user_model() 43 | username = jwt_get_username_from_payload(payload) 44 | token = jwt_get_knox_token_from_payload(payload) 45 | 46 | if not username or not token: 47 | msg = _('Invalid payload.') 48 | raise exceptions.AuthenticationFailed(msg) 49 | 50 | try: 51 | user = User.objects.get_by_natural_key(username) 52 | except User.DoesNotExist: 53 | msg = _('Invalid signature.') 54 | raise exceptions.AuthenticationFailed(msg) 55 | 56 | if not user.is_active: 57 | msg = _('User inactive or deleted.') 58 | raise exceptions.AuthenticationFailed(msg) 59 | 60 | return (user, self.ensure_valid_auth_token(user, token)) 61 | 62 | def ensure_valid_auth_token(self, user, token: AuthToken): 63 | for auth_token in AuthToken.objects.filter(user=user): 64 | if auth_token.expiry is not None and auth_token.expiry < timezone.now(): 65 | auth_token.delete() 66 | continue 67 | digest = hash_token(token) 68 | if digest == auth_token.digest: 69 | return auth_token 70 | 71 | msg = _('Invalid token.') 72 | raise exceptions.AuthenticationFailed(msg) 73 | 74 | 75 | class JSONWebTokenKnoxAuthentication(BaseJWTTAuthentication): 76 | """ 77 | Clients should authenticate by passing the JWT token in the "Authorization" 78 | HTTP header, prepended with the `JWT_AUTH_HEADER_PREFIX` string. For example: 79 | 80 | Authorization: Bearer abc.def.ghi 81 | """ 82 | www_authenticate_realm = 'api' 83 | 84 | def get_jwt_value(self, request): 85 | auth = get_authorization_header(request).split() 86 | auth_header_prefix = api_settings.JWT_AUTH_HEADER_PREFIX.lower() 87 | 88 | if not auth or smart_str(auth[0].lower()) != auth_header_prefix: 89 | return None 90 | 91 | if len(auth) == 1: 92 | msg = _('Invalid Authorization header. No credentials provided.') 93 | raise exceptions.AuthenticationFailed(msg) 94 | elif len(auth) > 2: 95 | msg = _('Invalid Authorization header. Credentials string ' 96 | 'should contain no spaces.') 97 | raise exceptions.AuthenticationFailed(msg) 98 | 99 | jwt_value = auth[1] 100 | 101 | try: 102 | payload = jwt_decode_handler(jwt_value) 103 | except jwt.ExpiredSignatureError: 104 | msg = _('Signature has expired.') 105 | raise exceptions.AuthenticationFailed(msg) 106 | except jwt.DecodeError: 107 | msg = _('Error decoding signature.') 108 | raise exceptions.AuthenticationFailed(msg) 109 | except jwt.InvalidTokenError: 110 | raise exceptions.AuthenticationFailed() 111 | 112 | return payload 113 | 114 | def authenticate_header(self, request): 115 | """ 116 | Return a string to be used as the value of WWW-Authenticate 117 | header in a 401 Unauthorized response, or None if the 118 | authentication scheme should return 403 Permission Denied respones. 119 | """ 120 | 121 | return '{0} realm="{1}"'.format(api_settings.JWT_AUTH_HEADER_PREFIX, 122 | self.www_authenticate_realm) 123 | -------------------------------------------------------------------------------- /jwt_knox/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ssaavedra/drf-jwt-knox/1017786ce7d9044fb613c845cf66393cdf585aad/jwt_knox/migrations/__init__.py -------------------------------------------------------------------------------- /jwt_knox/settings.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.conf import settings 4 | from rest_framework.settings import APISettings 5 | 6 | USER_SETTINGS = getattr(settings, 'JWT_AUTH', None) 7 | 8 | DEFAULTS = { 9 | 'JWT_LOGIN_AUTHENTICATION_CLASSES': settings.REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'], 10 | 'JWT_ENCODE_HANDLER': 'jwt_knox.utils.jwt_encode_handler', 11 | 'JWT_DECODE_HANDLER': 'jwt_knox.utils.jwt_decode_handler', 12 | 'JWT_PAYLOAD_HANDLER': 'jwt_knox.utils.jwt_payload_handler', 13 | 'JWT_PAYLOAD_GET_USERNAME_HANDLER': 'jwt_knox.utils.jwt_get_username_from_payload_handler', 14 | 'JWT_PAYLOAD_GET_TOKEN_HANDLER': 'jwt_knox.utils.jwt_get_token_from_payload_handler', 15 | 'JWT_RESPONSE_PAYLOAD_HANDLER': 'jwt_knox.utils.jwt_response_payload_handler', 16 | 'JWT_SECRET_KEY': settings.SECRET_KEY, 17 | 'JWT_ALGORITHM': 'HS256', 18 | 'JWT_AUTH_HEADER_PREFIX': 'Bearer', 19 | 'JWT_AUDIENCE': None, 20 | 'JWT_ISSUER': None, 21 | 'JWT_LEEWAY': 0, 22 | } 23 | 24 | IMPORT_STRINGS = ( 25 | 'JWT_LOGIN_AUTHENTICATION_CLASSES', 26 | 'JWT_ENCODE_HANDLER', 27 | 'JWT_DECODE_HANDLER', 28 | 'JWT_PAYLOAD_HANDLER', 29 | 'JWT_PAYLOAD_GET_USERNAME_HANDLER', 30 | 'JWT_PAYLOAD_GET_TOKEN_HANDLER', 31 | 'JWT_RESPONSE_PAYLOAD_HANDLER', 32 | ) 33 | 34 | 35 | api_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS) 36 | 37 | -------------------------------------------------------------------------------- /jwt_knox/urls.py: -------------------------------------------------------------------------------- 1 | """jwt_knox urls.py 2 | 3 | """ 4 | 5 | from rest_framework import routers 6 | 7 | from .viewsets import JWTKnoxAPIViewSet 8 | 9 | router = routers.SimpleRouter(trailing_slash=False) 10 | router.register(r'', JWTKnoxAPIViewSet, basename='jwt_knox') 11 | 12 | urlpatterns = router.urls 13 | -------------------------------------------------------------------------------- /jwt_knox/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | import jwt 3 | import uuid 4 | from calendar import timegm 5 | from datetime import datetime 6 | 7 | from django.contrib.auth import get_user_model 8 | 9 | from knox.models import AuthToken, User 10 | 11 | from jwt_knox.settings import api_settings 12 | 13 | 14 | def get_username_field(): 15 | try: 16 | username_field = get_user_model().USERNAME_FIELD 17 | except: 18 | username_field = 'username' 19 | 20 | return username_field 21 | 22 | 23 | def get_username(user): 24 | try: 25 | username = user.get_username() 26 | except AttributeError: 27 | username = user.username 28 | 29 | return username 30 | 31 | 32 | def create_auth_token(user, expiry): 33 | _, token = AuthToken.objects.create(user=user, expiry=expiry) 34 | payload = jwt_payload_handler(user, token, expiry) 35 | 36 | return jwt_encode_handler(payload) 37 | 38 | 39 | def jwt_get_token_from_payload_handler(payload): 40 | return payload.get('jti') 41 | 42 | 43 | def jwt_get_username_from_payload_handler(payload): 44 | return payload.get('username') 45 | 46 | 47 | def jwt_payload_handler(user: User, token: str, expiry: Optional[datetime]): 48 | username_field = get_username_field() 49 | username = get_username(user) 50 | 51 | payload = { 52 | 'username': username, 53 | 'iat': timegm(datetime.utcnow().utctimetuple()), 54 | 'jti': token, 55 | } 56 | 57 | if expiry: 58 | payload['exp'] = datetime.utcnow() + expiry 59 | 60 | if isinstance(user.pk, uuid.UUID): 61 | payload['user_id'] = str(user.pk) 62 | 63 | payload[username_field] = username 64 | 65 | if api_settings.JWT_AUDIENCE is not None: 66 | payload['aud'] = api_settings.JWT_AUDIENCE 67 | 68 | if api_settings.JWT_ISSUER is not None: 69 | payload['iss'] = api_settings.JWT_ISSUER 70 | 71 | return payload 72 | 73 | 74 | def jwt_encode_handler(payload): 75 | return jwt.encode(payload, api_settings.JWT_SECRET_KEY, 76 | api_settings.JWT_ALGORITHM) 77 | 78 | 79 | def jwt_decode_handler(token): 80 | options = {'verify_exp': True, } 81 | 82 | return jwt.decode( 83 | token, 84 | api_settings.JWT_SECRET_KEY, 85 | algorithms=api_settings.JWT_ALGORITHM, 86 | options=options, 87 | leeway=api_settings.JWT_LEEWAY, 88 | audience=api_settings.JWT_AUDIENCE, 89 | issuer=api_settings.JWT_ISSUER, 90 | ) 91 | 92 | 93 | def jwt_join_header_and_token(token): 94 | return "{0} {1}".format(api_settings.JWT_AUTH_HEADER_PREFIX, 95 | token, ) 96 | 97 | 98 | def jwt_response_payload_handler(token, user=None, request=None): 99 | return {'token': jwt_join_header_and_token(token), } 100 | -------------------------------------------------------------------------------- /jwt_knox/viewsets.py: -------------------------------------------------------------------------------- 1 | from rest_framework import status 2 | from rest_framework.decorators import action 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.request import ForcedAuthentication 5 | from rest_framework.response import Response 6 | from rest_framework.viewsets import ViewSet 7 | 8 | from jwt_knox.auth import JSONWebTokenKnoxAuthentication 9 | from jwt_knox.settings import api_settings 10 | from jwt_knox.utils import create_auth_token 11 | 12 | response_payload_handler = api_settings.JWT_RESPONSE_PAYLOAD_HANDLER 13 | 14 | class PerViewAuthenticatorMixin(object): 15 | def initialize_request(self, request, *args, **kwargs): 16 | """ 17 | Returns the initial request object. 18 | """ 19 | request = super(PerViewAuthenticatorMixin, self).initialize_request(request, *args, **kwargs) 20 | if not any([isinstance(auth, ForcedAuthentication) for auth in request.authenticators]): 21 | request.authenticators = self.get_authenticators() 22 | return request 23 | 24 | def get_authenticators(self): 25 | """ 26 | First tries to get the specific authenticators for a view by 27 | calling `.get_authenticators_for_view`, but falls back on the 28 | class's authenticators. 29 | """ 30 | authenticators = self.authentication_classes or () 31 | 32 | if hasattr(self, 'action'): 33 | # action gets populated on the second time we are called 34 | per_view = self.get_authenticators_for_view(self.action) 35 | if per_view is not None: 36 | authenticators = per_view 37 | 38 | return [auth() for auth in authenticators] 39 | 40 | def get_authenticators_for_view(self, view_name): 41 | """ 42 | Define the authenticators present in a specific view. 43 | """ 44 | pass 45 | 46 | 47 | class JWTKnoxAPIViewSet(PerViewAuthenticatorMixin, ViewSet): 48 | """This API endpoint set enables authentication via **JSON Web Tokens** (JWT). 49 | 50 | The provided JWTs are meant for a single device only and to be 51 | kept secret. The tokens may be set to expire after a certain time 52 | (see `get_token`). The tokens may be revoked in the server via the 53 | diverse logout endpoints. The JWTs are database-backed and may be 54 | revoked at any time. 55 | """ 56 | base_name = 'jwt_knox' 57 | 58 | authentication_classes = (JSONWebTokenKnoxAuthentication, ) 59 | permission_classes = (IsAuthenticated, ) 60 | 61 | def get_authenticators_for_view(self, view_name): 62 | if view_name == 'get_token': 63 | return api_settings.JWT_LOGIN_AUTHENTICATION_CLASSES 64 | 65 | @action(methods=['post', ], detail=False) 66 | def get_token(self, request, expiry=None): 67 | """ 68 | This view authenticates a user via the 69 | `JWT_LOGIN_AUTHENTICATION_CLASSES` (which, in turn, defaults to 70 | rest_framework's `DEFAULT_AUTHENTICATION_CLASSES`) to get a view 71 | token. 72 | """ 73 | token = create_auth_token(user=request.user, expiry=expiry) 74 | return Response(response_payload_handler(token, request.user, request)) 75 | 76 | @action(methods=('get', 'post'), detail=False) 77 | def verify(self, request): 78 | """ 79 | This view allows a third party to verify a web token. 80 | """ 81 | return Response(None, status=status.HTTP_204_NO_CONTENT) 82 | 83 | @action(methods=('get', ), detail=False) 84 | def debug_verify(self, request): 85 | """ 86 | This view returns internal data on the token, the user and the current 87 | request. 88 | 89 | **NOT TO BE USED IN PROUDCTION.** 90 | """ 91 | token = request.auth[0] 92 | return Response( 93 | response_payload_handler(token, request.user, request), 94 | status=status.HTTP_200_OK) 95 | 96 | @action(methods=('post', ), detail=False) 97 | def logout(self, request): 98 | """ 99 | Invalidates the current token, so that it cannot be used anymore 100 | for authentication. 101 | """ 102 | request.auth[1].delete() 103 | return Response(None, status=status.HTTP_204_NO_CONTENT) 104 | 105 | @action(methods=('post', ), detail=False) 106 | def logout_other(self, request): 107 | """ 108 | Invalidates all the tokens except the current one, so that all other 109 | remaining open sessions get closed and only the current one is still 110 | open. 111 | """ 112 | tokens_to_delete = request.user.auth_token_set.exclude( 113 | pk=request.auth[1].pk) 114 | num = tokens_to_delete.delete() 115 | return Response({"deleted_sessions": num[0]}) 116 | 117 | @action(methods=('post', ), detail=False) 118 | def logout_all(self, request): 119 | """ 120 | Invalidates all currently valid tokens for the user, including the 121 | current session. This endpoint invalidates the current token, and you 122 | will need to authenticate again. 123 | """ 124 | request.user.auth_token_set.all().delete() 125 | return Response(None, status=status.HTTP_204_NO_CONTENT) 126 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "asgiref" 5 | version = "3.6.0" 6 | description = "ASGI specs, helper code, and adapters" 7 | optional = false 8 | python-versions = ">=3.7" 9 | files = [ 10 | {file = "asgiref-3.6.0-py3-none-any.whl", hash = "sha256:71e68008da809b957b7ee4b43dbccff33d1b23519fb8344e33f049897077afac"}, 11 | {file = "asgiref-3.6.0.tar.gz", hash = "sha256:9567dfe7bd8d3c8c892227827c41cce860b368104c3431da67a0c5a65a949506"}, 12 | ] 13 | 14 | [package.extras] 15 | tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] 16 | 17 | [[package]] 18 | name = "cachetools" 19 | version = "5.3.0" 20 | description = "Extensible memoizing collections and decorators" 21 | optional = false 22 | python-versions = "~=3.7" 23 | files = [ 24 | {file = "cachetools-5.3.0-py3-none-any.whl", hash = "sha256:429e1a1e845c008ea6c85aa35d4b98b65d6a9763eeef3e37e92728a12d1de9d4"}, 25 | {file = "cachetools-5.3.0.tar.gz", hash = "sha256:13dfddc7b8df938c21a940dfa6557ce6e94a2f1cdfa58eb90c805721d58f2c14"}, 26 | ] 27 | 28 | [[package]] 29 | name = "cffi" 30 | version = "1.15.1" 31 | description = "Foreign Function Interface for Python calling C code." 32 | optional = false 33 | python-versions = "*" 34 | files = [ 35 | {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, 36 | {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, 37 | {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, 38 | {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, 39 | {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, 40 | {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, 41 | {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, 42 | {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, 43 | {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, 44 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, 45 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, 46 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, 47 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, 48 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, 49 | {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, 50 | {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, 51 | {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, 52 | {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, 53 | {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, 54 | {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, 55 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, 56 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, 57 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, 58 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, 59 | {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, 60 | {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, 61 | {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, 62 | {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, 63 | {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, 64 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, 65 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, 66 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, 67 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, 68 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, 69 | {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, 70 | {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, 71 | {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, 72 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, 73 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, 74 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, 75 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, 76 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, 77 | {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, 78 | {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, 79 | {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, 80 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, 81 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, 82 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, 83 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, 84 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, 85 | {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, 86 | {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, 87 | {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, 88 | {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, 89 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, 90 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, 91 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, 92 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, 93 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, 94 | {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, 95 | {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, 96 | {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, 97 | {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, 98 | {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, 99 | ] 100 | 101 | [package.dependencies] 102 | pycparser = "*" 103 | 104 | [[package]] 105 | name = "chardet" 106 | version = "5.1.0" 107 | description = "Universal encoding detector for Python 3" 108 | optional = false 109 | python-versions = ">=3.7" 110 | files = [ 111 | {file = "chardet-5.1.0-py3-none-any.whl", hash = "sha256:362777fb014af596ad31334fde1e8c327dfdb076e1960d1694662d46a6917ab9"}, 112 | {file = "chardet-5.1.0.tar.gz", hash = "sha256:0d62712b956bc154f85fb0a266e2a3c5913c2967e00348701b32411d6def31e5"}, 113 | ] 114 | 115 | [[package]] 116 | name = "colorama" 117 | version = "0.4.6" 118 | description = "Cross-platform colored terminal text." 119 | optional = false 120 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 121 | files = [ 122 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 123 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 124 | ] 125 | 126 | [[package]] 127 | name = "coverage" 128 | version = "7.2.5" 129 | description = "Code coverage measurement for Python" 130 | optional = false 131 | python-versions = ">=3.7" 132 | files = [ 133 | {file = "coverage-7.2.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:883123d0bbe1c136f76b56276074b0c79b5817dd4238097ffa64ac67257f4b6c"}, 134 | {file = "coverage-7.2.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2fbc2a127e857d2f8898aaabcc34c37771bf78a4d5e17d3e1f5c30cd0cbc62a"}, 135 | {file = "coverage-7.2.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f3671662dc4b422b15776cdca89c041a6349b4864a43aa2350b6b0b03bbcc7f"}, 136 | {file = "coverage-7.2.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780551e47d62095e088f251f5db428473c26db7829884323e56d9c0c3118791a"}, 137 | {file = "coverage-7.2.5-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:066b44897c493e0dcbc9e6a6d9f8bbb6607ef82367cf6810d387c09f0cd4fe9a"}, 138 | {file = "coverage-7.2.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9a4ee55174b04f6af539218f9f8083140f61a46eabcaa4234f3c2a452c4ed11"}, 139 | {file = "coverage-7.2.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:706ec567267c96717ab9363904d846ec009a48d5f832140b6ad08aad3791b1f5"}, 140 | {file = "coverage-7.2.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ae453f655640157d76209f42c62c64c4d4f2c7f97256d3567e3b439bd5c9b06c"}, 141 | {file = "coverage-7.2.5-cp310-cp310-win32.whl", hash = "sha256:f81c9b4bd8aa747d417407a7f6f0b1469a43b36a85748145e144ac4e8d303cb5"}, 142 | {file = "coverage-7.2.5-cp310-cp310-win_amd64.whl", hash = "sha256:dc945064a8783b86fcce9a0a705abd7db2117d95e340df8a4333f00be5efb64c"}, 143 | {file = "coverage-7.2.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:40cc0f91c6cde033da493227797be2826cbf8f388eaa36a0271a97a332bfd7ce"}, 144 | {file = "coverage-7.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a66e055254a26c82aead7ff420d9fa8dc2da10c82679ea850d8feebf11074d88"}, 145 | {file = "coverage-7.2.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c10fbc8a64aa0f3ed136b0b086b6b577bc64d67d5581acd7cc129af52654384e"}, 146 | {file = "coverage-7.2.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a22cbb5ede6fade0482111fa7f01115ff04039795d7092ed0db43522431b4f2"}, 147 | {file = "coverage-7.2.5-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292300f76440651529b8ceec283a9370532f4ecba9ad67d120617021bb5ef139"}, 148 | {file = "coverage-7.2.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7ff8f3fb38233035028dbc93715551d81eadc110199e14bbbfa01c5c4a43f8d8"}, 149 | {file = "coverage-7.2.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:a08c7401d0b24e8c2982f4e307124b671c6736d40d1c39e09d7a8687bddf83ed"}, 150 | {file = "coverage-7.2.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef9659d1cda9ce9ac9585c045aaa1e59223b143f2407db0eaee0b61a4f266fb6"}, 151 | {file = "coverage-7.2.5-cp311-cp311-win32.whl", hash = "sha256:30dcaf05adfa69c2a7b9f7dfd9f60bc8e36b282d7ed25c308ef9e114de7fc23b"}, 152 | {file = "coverage-7.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:97072cc90f1009386c8a5b7de9d4fc1a9f91ba5ef2146c55c1f005e7b5c5e068"}, 153 | {file = "coverage-7.2.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bebea5f5ed41f618797ce3ffb4606c64a5de92e9c3f26d26c2e0aae292f015c1"}, 154 | {file = "coverage-7.2.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:828189fcdda99aae0d6bf718ea766b2e715eabc1868670a0a07bf8404bf58c33"}, 155 | {file = "coverage-7.2.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e8a95f243d01ba572341c52f89f3acb98a3b6d1d5d830efba86033dd3687ade"}, 156 | {file = "coverage-7.2.5-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8834e5f17d89e05697c3c043d3e58a8b19682bf365048837383abfe39adaed5"}, 157 | {file = "coverage-7.2.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d1f25ee9de21a39b3a8516f2c5feb8de248f17da7eead089c2e04aa097936b47"}, 158 | {file = "coverage-7.2.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1637253b11a18f453e34013c665d8bf15904c9e3c44fbda34c643fbdc9d452cd"}, 159 | {file = "coverage-7.2.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8e575a59315a91ccd00c7757127f6b2488c2f914096077c745c2f1ba5b8c0969"}, 160 | {file = "coverage-7.2.5-cp37-cp37m-win32.whl", hash = "sha256:509ecd8334c380000d259dc66feb191dd0a93b21f2453faa75f7f9cdcefc0718"}, 161 | {file = "coverage-7.2.5-cp37-cp37m-win_amd64.whl", hash = "sha256:12580845917b1e59f8a1c2ffa6af6d0908cb39220f3019e36c110c943dc875b0"}, 162 | {file = "coverage-7.2.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b5016e331b75310610c2cf955d9f58a9749943ed5f7b8cfc0bb89c6134ab0a84"}, 163 | {file = "coverage-7.2.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:373ea34dca98f2fdb3e5cb33d83b6d801007a8074f992b80311fc589d3e6b790"}, 164 | {file = "coverage-7.2.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a063aad9f7b4c9f9da7b2550eae0a582ffc7623dca1c925e50c3fbde7a579771"}, 165 | {file = "coverage-7.2.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38c0a497a000d50491055805313ed83ddba069353d102ece8aef5d11b5faf045"}, 166 | {file = "coverage-7.2.5-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2b3b05e22a77bb0ae1a3125126a4e08535961c946b62f30985535ed40e26614"}, 167 | {file = "coverage-7.2.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0342a28617e63ad15d96dca0f7ae9479a37b7d8a295f749c14f3436ea59fdcb3"}, 168 | {file = "coverage-7.2.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cf97ed82ca986e5c637ea286ba2793c85325b30f869bf64d3009ccc1a31ae3fd"}, 169 | {file = "coverage-7.2.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c2c41c1b1866b670573657d584de413df701f482574bad7e28214a2362cb1fd1"}, 170 | {file = "coverage-7.2.5-cp38-cp38-win32.whl", hash = "sha256:10b15394c13544fce02382360cab54e51a9e0fd1bd61ae9ce012c0d1e103c813"}, 171 | {file = "coverage-7.2.5-cp38-cp38-win_amd64.whl", hash = "sha256:a0b273fe6dc655b110e8dc89b8ec7f1a778d78c9fd9b4bda7c384c8906072212"}, 172 | {file = "coverage-7.2.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c587f52c81211d4530fa6857884d37f514bcf9453bdeee0ff93eaaf906a5c1b"}, 173 | {file = "coverage-7.2.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4436cc9ba5414c2c998eaedee5343f49c02ca93b21769c5fdfa4f9d799e84200"}, 174 | {file = "coverage-7.2.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6599bf92f33ab041e36e06d25890afbdf12078aacfe1f1d08c713906e49a3fe5"}, 175 | {file = "coverage-7.2.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:857abe2fa6a4973f8663e039ead8d22215d31db613ace76e4a98f52ec919068e"}, 176 | {file = "coverage-7.2.5-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6f5cab2d7f0c12f8187a376cc6582c477d2df91d63f75341307fcdcb5d60303"}, 177 | {file = "coverage-7.2.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:aa387bd7489f3e1787ff82068b295bcaafbf6f79c3dad3cbc82ef88ce3f48ad3"}, 178 | {file = "coverage-7.2.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:156192e5fd3dbbcb11cd777cc469cf010a294f4c736a2b2c891c77618cb1379a"}, 179 | {file = "coverage-7.2.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bd3b4b8175c1db502adf209d06136c000df4d245105c8839e9d0be71c94aefe1"}, 180 | {file = "coverage-7.2.5-cp39-cp39-win32.whl", hash = "sha256:ddc5a54edb653e9e215f75de377354e2455376f416c4378e1d43b08ec50acc31"}, 181 | {file = "coverage-7.2.5-cp39-cp39-win_amd64.whl", hash = "sha256:338aa9d9883aaaad53695cb14ccdeb36d4060485bb9388446330bef9c361c252"}, 182 | {file = "coverage-7.2.5-pp37.pp38.pp39-none-any.whl", hash = "sha256:8877d9b437b35a85c18e3c6499b23674684bf690f5d96c1006a1ef61f9fdf0f3"}, 183 | {file = "coverage-7.2.5.tar.gz", hash = "sha256:f99ef080288f09ffc687423b8d60978cf3a465d3f404a18d1a05474bd8575a47"}, 184 | ] 185 | 186 | [package.dependencies] 187 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 188 | 189 | [package.extras] 190 | toml = ["tomli"] 191 | 192 | [[package]] 193 | name = "cryptography" 194 | version = "41.0.3" 195 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." 196 | optional = false 197 | python-versions = ">=3.7" 198 | files = [ 199 | {file = "cryptography-41.0.3-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:652627a055cb52a84f8c448185922241dd5217443ca194d5739b44612c5e6507"}, 200 | {file = "cryptography-41.0.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:8f09daa483aedea50d249ef98ed500569841d6498aa9c9f4b0531b9964658922"}, 201 | {file = "cryptography-41.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fd871184321100fb400d759ad0cddddf284c4b696568204d281c902fc7b0d81"}, 202 | {file = "cryptography-41.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84537453d57f55a50a5b6835622ee405816999a7113267739a1b4581f83535bd"}, 203 | {file = "cryptography-41.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3fb248989b6363906827284cd20cca63bb1a757e0a2864d4c1682a985e3dca47"}, 204 | {file = "cryptography-41.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:42cb413e01a5d36da9929baa9d70ca90d90b969269e5a12d39c1e0d475010116"}, 205 | {file = "cryptography-41.0.3-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:aeb57c421b34af8f9fe830e1955bf493a86a7996cc1338fe41b30047d16e962c"}, 206 | {file = "cryptography-41.0.3-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6af1c6387c531cd364b72c28daa29232162010d952ceb7e5ca8e2827526aceae"}, 207 | {file = "cryptography-41.0.3-cp37-abi3-win32.whl", hash = "sha256:0d09fb5356f975974dbcb595ad2d178305e5050656affb7890a1583f5e02a306"}, 208 | {file = "cryptography-41.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:a983e441a00a9d57a4d7c91b3116a37ae602907a7618b882c8013b5762e80574"}, 209 | {file = "cryptography-41.0.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5259cb659aa43005eb55a0e4ff2c825ca111a0da1814202c64d28a985d33b087"}, 210 | {file = "cryptography-41.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:67e120e9a577c64fe1f611e53b30b3e69744e5910ff3b6e97e935aeb96005858"}, 211 | {file = "cryptography-41.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7efe8041897fe7a50863e51b77789b657a133c75c3b094e51b5e4b5cec7bf906"}, 212 | {file = "cryptography-41.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ce785cf81a7bdade534297ef9e490ddff800d956625020ab2ec2780a556c313e"}, 213 | {file = "cryptography-41.0.3-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:57a51b89f954f216a81c9d057bf1a24e2f36e764a1ca9a501a6964eb4a6800dd"}, 214 | {file = "cryptography-41.0.3-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c2f0d35703d61002a2bbdcf15548ebb701cfdd83cdc12471d2bae80878a4207"}, 215 | {file = "cryptography-41.0.3-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:23c2d778cf829f7d0ae180600b17e9fceea3c2ef8b31a99e3c694cbbf3a24b84"}, 216 | {file = "cryptography-41.0.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:95dd7f261bb76948b52a5330ba5202b91a26fbac13ad0e9fc8a3ac04752058c7"}, 217 | {file = "cryptography-41.0.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:41d7aa7cdfded09b3d73a47f429c298e80796c8e825ddfadc84c8a7f12df212d"}, 218 | {file = "cryptography-41.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d0d651aa754ef58d75cec6edfbd21259d93810b73f6ec246436a21b7841908de"}, 219 | {file = "cryptography-41.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ab8de0d091acbf778f74286f4989cf3d1528336af1b59f3e5d2ebca8b5fe49e1"}, 220 | {file = "cryptography-41.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a74fbcdb2a0d46fe00504f571a2a540532f4c188e6ccf26f1f178480117b33c4"}, 221 | {file = "cryptography-41.0.3.tar.gz", hash = "sha256:6d192741113ef5e30d89dcb5b956ef4e1578f304708701b8b73d38e3e1461f34"}, 222 | ] 223 | 224 | [package.dependencies] 225 | cffi = ">=1.12" 226 | 227 | [package.extras] 228 | docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] 229 | docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] 230 | nox = ["nox"] 231 | pep8test = ["black", "check-sdist", "mypy", "ruff"] 232 | sdist = ["build"] 233 | ssh = ["bcrypt (>=3.1.5)"] 234 | test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] 235 | test-randomorder = ["pytest-randomly"] 236 | 237 | [[package]] 238 | name = "distlib" 239 | version = "0.3.6" 240 | description = "Distribution utilities" 241 | optional = false 242 | python-versions = "*" 243 | files = [ 244 | {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, 245 | {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, 246 | ] 247 | 248 | [[package]] 249 | name = "django" 250 | version = "4.2.1" 251 | description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." 252 | optional = false 253 | python-versions = ">=3.8" 254 | files = [ 255 | {file = "Django-4.2.1-py3-none-any.whl", hash = "sha256:066b6debb5ac335458d2a713ed995570536c8b59a580005acb0732378d5eb1ee"}, 256 | {file = "Django-4.2.1.tar.gz", hash = "sha256:7efa6b1f781a6119a10ac94b4794ded90db8accbe7802281cd26f8664ffed59c"}, 257 | ] 258 | 259 | [package.dependencies] 260 | asgiref = ">=3.6.0,<4" 261 | sqlparse = ">=0.3.1" 262 | tzdata = {version = "*", markers = "sys_platform == \"win32\""} 263 | 264 | [package.extras] 265 | argon2 = ["argon2-cffi (>=19.1.0)"] 266 | bcrypt = ["bcrypt"] 267 | 268 | [[package]] 269 | name = "django-rest-knox" 270 | version = "4.2.0" 271 | description = "Authentication for django rest framework" 272 | optional = false 273 | python-versions = ">=3.6" 274 | files = [ 275 | {file = "django-rest-knox-4.2.0.tar.gz", hash = "sha256:4595f1dc23d6e41af7939e5f2d8fdaf6ade0a74a656218e7b56683db5566fcc9"}, 276 | {file = "django_rest_knox-4.2.0-py3-none-any.whl", hash = "sha256:62b8e374a44cd4e9617eaefe27c915b301bf224fa6550633d3013d3f9f415113"}, 277 | ] 278 | 279 | [package.dependencies] 280 | cryptography = "*" 281 | django = ">=3.2" 282 | djangorestframework = "*" 283 | 284 | [[package]] 285 | name = "djangorestframework" 286 | version = "3.14.0" 287 | description = "Web APIs for Django, made easy." 288 | optional = false 289 | python-versions = ">=3.6" 290 | files = [ 291 | {file = "djangorestframework-3.14.0-py3-none-any.whl", hash = "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08"}, 292 | {file = "djangorestframework-3.14.0.tar.gz", hash = "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8"}, 293 | ] 294 | 295 | [package.dependencies] 296 | django = ">=3.0" 297 | pytz = "*" 298 | 299 | [[package]] 300 | name = "exceptiongroup" 301 | version = "1.1.1" 302 | description = "Backport of PEP 654 (exception groups)" 303 | optional = false 304 | python-versions = ">=3.7" 305 | files = [ 306 | {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, 307 | {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, 308 | ] 309 | 310 | [package.extras] 311 | test = ["pytest (>=6)"] 312 | 313 | [[package]] 314 | name = "filelock" 315 | version = "3.12.0" 316 | description = "A platform independent file lock." 317 | optional = false 318 | python-versions = ">=3.7" 319 | files = [ 320 | {file = "filelock-3.12.0-py3-none-any.whl", hash = "sha256:ad98852315c2ab702aeb628412cbf7e95b7ce8c3bf9565670b4eaecf1db370a9"}, 321 | {file = "filelock-3.12.0.tar.gz", hash = "sha256:fc03ae43288c013d2ea83c8597001b1129db351aad9c57fe2409327916b8e718"}, 322 | ] 323 | 324 | [package.extras] 325 | docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] 326 | testing = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] 327 | 328 | [[package]] 329 | name = "iniconfig" 330 | version = "2.0.0" 331 | description = "brain-dead simple config-ini parsing" 332 | optional = false 333 | python-versions = ">=3.7" 334 | files = [ 335 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 336 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 337 | ] 338 | 339 | [[package]] 340 | name = "packaging" 341 | version = "23.1" 342 | description = "Core utilities for Python packages" 343 | optional = false 344 | python-versions = ">=3.7" 345 | files = [ 346 | {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, 347 | {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, 348 | ] 349 | 350 | [[package]] 351 | name = "platformdirs" 352 | version = "3.5.1" 353 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 354 | optional = false 355 | python-versions = ">=3.7" 356 | files = [ 357 | {file = "platformdirs-3.5.1-py3-none-any.whl", hash = "sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5"}, 358 | {file = "platformdirs-3.5.1.tar.gz", hash = "sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f"}, 359 | ] 360 | 361 | [package.extras] 362 | docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.2.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] 363 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] 364 | 365 | [[package]] 366 | name = "pluggy" 367 | version = "1.0.0" 368 | description = "plugin and hook calling mechanisms for python" 369 | optional = false 370 | python-versions = ">=3.6" 371 | files = [ 372 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 373 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 374 | ] 375 | 376 | [package.extras] 377 | dev = ["pre-commit", "tox"] 378 | testing = ["pytest", "pytest-benchmark"] 379 | 380 | [[package]] 381 | name = "pycparser" 382 | version = "2.21" 383 | description = "C parser in Python" 384 | optional = false 385 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 386 | files = [ 387 | {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, 388 | {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, 389 | ] 390 | 391 | [[package]] 392 | name = "pyjwt" 393 | version = "2.7.0" 394 | description = "JSON Web Token implementation in Python" 395 | optional = false 396 | python-versions = ">=3.7" 397 | files = [ 398 | {file = "PyJWT-2.7.0-py3-none-any.whl", hash = "sha256:ba2b425b15ad5ef12f200dc67dd56af4e26de2331f965c5439994dad075876e1"}, 399 | {file = "PyJWT-2.7.0.tar.gz", hash = "sha256:bd6ca4a3c4285c1a2d4349e5a035fdf8fb94e04ccd0fcbe6ba289dae9cc3e074"}, 400 | ] 401 | 402 | [package.extras] 403 | crypto = ["cryptography (>=3.4.0)"] 404 | dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] 405 | docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] 406 | tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] 407 | 408 | [[package]] 409 | name = "pyproject-api" 410 | version = "1.5.1" 411 | description = "API to interact with the python pyproject.toml based projects" 412 | optional = false 413 | python-versions = ">=3.7" 414 | files = [ 415 | {file = "pyproject_api-1.5.1-py3-none-any.whl", hash = "sha256:4698a3777c2e0f6b624f8a4599131e2a25376d90fe8d146d7ac74c67c6f97c43"}, 416 | {file = "pyproject_api-1.5.1.tar.gz", hash = "sha256:435f46547a9ff22cf4208ee274fca3e2869aeb062a4834adfc99a4dd64af3cf9"}, 417 | ] 418 | 419 | [package.dependencies] 420 | packaging = ">=23" 421 | tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} 422 | 423 | [package.extras] 424 | docs = ["furo (>=2022.12.7)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] 425 | testing = ["covdefaults (>=2.2.2)", "importlib-metadata (>=6)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "virtualenv (>=20.17.1)", "wheel (>=0.38.4)"] 426 | 427 | [[package]] 428 | name = "pytest" 429 | version = "7.3.1" 430 | description = "pytest: simple powerful testing with Python" 431 | optional = false 432 | python-versions = ">=3.7" 433 | files = [ 434 | {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, 435 | {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, 436 | ] 437 | 438 | [package.dependencies] 439 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 440 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 441 | iniconfig = "*" 442 | packaging = "*" 443 | pluggy = ">=0.12,<2.0" 444 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 445 | 446 | [package.extras] 447 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 448 | 449 | [[package]] 450 | name = "pytest-cov" 451 | version = "4.0.0" 452 | description = "Pytest plugin for measuring coverage." 453 | optional = false 454 | python-versions = ">=3.6" 455 | files = [ 456 | {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, 457 | {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, 458 | ] 459 | 460 | [package.dependencies] 461 | coverage = {version = ">=5.2.1", extras = ["toml"]} 462 | pytest = ">=4.6" 463 | 464 | [package.extras] 465 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] 466 | 467 | [[package]] 468 | name = "pytest-django" 469 | version = "4.5.2" 470 | description = "A Django plugin for pytest." 471 | optional = false 472 | python-versions = ">=3.5" 473 | files = [ 474 | {file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"}, 475 | {file = "pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e"}, 476 | ] 477 | 478 | [package.dependencies] 479 | pytest = ">=5.4.0" 480 | 481 | [package.extras] 482 | docs = ["sphinx", "sphinx-rtd-theme"] 483 | testing = ["Django", "django-configurations (>=2.0)"] 484 | 485 | [[package]] 486 | name = "pytz" 487 | version = "2023.3" 488 | description = "World timezone definitions, modern and historical" 489 | optional = false 490 | python-versions = "*" 491 | files = [ 492 | {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"}, 493 | {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, 494 | ] 495 | 496 | [[package]] 497 | name = "sqlparse" 498 | version = "0.4.4" 499 | description = "A non-validating SQL parser." 500 | optional = false 501 | python-versions = ">=3.5" 502 | files = [ 503 | {file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"}, 504 | {file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"}, 505 | ] 506 | 507 | [package.extras] 508 | dev = ["build", "flake8"] 509 | doc = ["sphinx"] 510 | test = ["pytest", "pytest-cov"] 511 | 512 | [[package]] 513 | name = "tomli" 514 | version = "2.0.1" 515 | description = "A lil' TOML parser" 516 | optional = false 517 | python-versions = ">=3.7" 518 | files = [ 519 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 520 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 521 | ] 522 | 523 | [[package]] 524 | name = "tox" 525 | version = "4.5.1" 526 | description = "tox is a generic virtualenv management and test command line tool" 527 | optional = false 528 | python-versions = ">=3.7" 529 | files = [ 530 | {file = "tox-4.5.1-py3-none-any.whl", hash = "sha256:d25a2e6cb261adc489604fafd76cd689efeadfa79709965e965668d6d3f63046"}, 531 | {file = "tox-4.5.1.tar.gz", hash = "sha256:5a2eac5fb816779dfdf5cb00fecbc27eb0524e4626626bb1de84747b24cacc56"}, 532 | ] 533 | 534 | [package.dependencies] 535 | cachetools = ">=5.3" 536 | chardet = ">=5.1" 537 | colorama = ">=0.4.6" 538 | filelock = ">=3.11" 539 | packaging = ">=23.1" 540 | platformdirs = ">=3.2" 541 | pluggy = ">=1" 542 | pyproject-api = ">=1.5.1" 543 | tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} 544 | virtualenv = ">=20.21" 545 | 546 | [package.extras] 547 | docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-argparse-cli (>=1.11)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2022.1.2b11)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] 548 | testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "devpi-process (>=0.3)", "diff-cover (>=7.5)", "distlib (>=0.3.6)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.14)", "psutil (>=5.9.4)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-xdist (>=3.2.1)", "re-assert (>=1.1)", "time-machine (>=2.9)", "wheel (>=0.40)"] 549 | 550 | [[package]] 551 | name = "tzdata" 552 | version = "2023.3" 553 | description = "Provider of IANA time zone data" 554 | optional = false 555 | python-versions = ">=2" 556 | files = [ 557 | {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"}, 558 | {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"}, 559 | ] 560 | 561 | [[package]] 562 | name = "virtualenv" 563 | version = "20.23.0" 564 | description = "Virtual Python Environment builder" 565 | optional = false 566 | python-versions = ">=3.7" 567 | files = [ 568 | {file = "virtualenv-20.23.0-py3-none-any.whl", hash = "sha256:6abec7670e5802a528357fdc75b26b9f57d5d92f29c5462ba0fbe45feacc685e"}, 569 | {file = "virtualenv-20.23.0.tar.gz", hash = "sha256:a85caa554ced0c0afbd0d638e7e2d7b5f92d23478d05d17a76daeac8f279f924"}, 570 | ] 571 | 572 | [package.dependencies] 573 | distlib = ">=0.3.6,<1" 574 | filelock = ">=3.11,<4" 575 | platformdirs = ">=3.2,<4" 576 | 577 | [package.extras] 578 | docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] 579 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.7.1)", "time-machine (>=2.9)"] 580 | 581 | [metadata] 582 | lock-version = "2.0" 583 | python-versions = "^3.9" 584 | content-hash = "c346ca7985d32d64aa9818e4bbf61d11a76574b9c06c7c0e200d585f186c2ca1" 585 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "drf-jwt-knox" 3 | description = "Knox-fortified JSON Web Tokens for Django REST Framework" 4 | authors = [ 5 | "Santiago Saavedra " 6 | ] 7 | license = "Apache2" 8 | readme = "README.md" 9 | python = "^3.6" 10 | homepage = "https://github.com/ssaavedra/drf-jwt-knox" 11 | repository = "https://github.com/ssaavedra/drf-jwt-knox" 12 | documentation = "https://github.com/ssaavedra/drf-jwt-knox" 13 | 14 | keywords = ["django", "jwt"] 15 | 16 | classifiers = [ 17 | 'Development Status :: 4 - Beta', 18 | 'Environment :: Web Environment', 19 | 'Framework :: Django', 20 | 'Intended Audience :: Developers', 21 | 'License :: OSI Approved :: Apache Software License', 22 | 'Operating System :: OS Independent', 23 | 'Programming Language :: Python :: 3', 24 | 'Topic :: Internet :: WWW/HTTP :: Session', 25 | ] 26 | 27 | [options] 28 | packages = ["jwt_knox"] 29 | 30 | # Requirements 31 | [dependencies] 32 | djangorestframework = "^3.14" 33 | django-rest-knox = "^4.2.0" 34 | PyJWT = "^2.7.0" 35 | 36 | 37 | [build-system] 38 | requires = ["setuptools>=67.7.2", "poetry-core>=1.6.0", "poetry-dynamic-versioning"] 39 | build-backend = "poetry.core.masonry.api" 40 | 41 | [tool.setuptools_scm] 42 | 43 | [tool.poetry] 44 | name = "drf-jwt-knox" 45 | description = "Knox-fortified JSON Web Tokens for Django REST Framework" 46 | authors = [ 47 | "Santiago Saavedra " 48 | ] 49 | license = "Apache2" 50 | readme = "README.md" 51 | packages = [ 52 | { include = "jwt_knox" }, 53 | ] 54 | version = "0.0.0" 55 | 56 | [tool.poetry.dependencies] 57 | djangorestframework = "^3.14" 58 | django-rest-knox = "^4.2.0" 59 | PyJWT = "^2.7.0" 60 | python = "^3.9" 61 | 62 | [tool.poetry.group.dev.dependencies] 63 | pytest = "^7.3.1" 64 | pytest-django = "^4.5.2" 65 | pytest-cov = "^4.0.0" 66 | tox = "^4.5.1" 67 | 68 | [tool.poetry-dynamic-versioning] 69 | enable = true 70 | vcs = "git" 71 | tagged-metadata = false 72 | dirty = true 73 | -------------------------------------------------------------------------------- /requirements/requirements-django32lts.txt: -------------------------------------------------------------------------------- 1 | Django~=3.2.20 2 | -------------------------------------------------------------------------------- /requirements/requirements-django41.txt: -------------------------------------------------------------------------------- 1 | Django~=4.2.3 2 | -------------------------------------------------------------------------------- /requirements/requirements-django42lts.txt: -------------------------------------------------------------------------------- 1 | Django~=4.2.3 2 | -------------------------------------------------------------------------------- /requirements/requirements-testing.txt: -------------------------------------------------------------------------------- 1 | flake8~=4.0 2 | isort~=5.10.1 3 | pep8~=1.7.1 4 | pytest~=7.3.1 5 | pytest-django~=4.5.2 6 | pytest-cov~=4.0.0 7 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | import os 3 | import subprocess 4 | import sys 5 | 6 | import pytest 7 | 8 | PYTEST_ARGS = { 9 | 'default': ['tests', '--tb=short', '-s', '-rw'], 10 | 'fast': ['tests', '--tb=short', '-q', '-s', '-rw'], 11 | } 12 | 13 | FLAKE8_ARGS = ['jwt_knox', 'tests', '--ignore=E501'] 14 | 15 | ISORT_ARGS = ['--recursive', '--check-only', '-o' 'uritemplate', '-p', 'tests', 'jwt_knox', 'tests'] 16 | 17 | sys.path.append(os.path.dirname(__file__)) 18 | 19 | 20 | def exit_on_failure(ret, message=None): 21 | if ret: 22 | sys.exit(ret) 23 | 24 | 25 | def flake8_main(args): 26 | print('Running flake8 code linting') 27 | ret = subprocess.call(['flake8'] + args) 28 | print('flake8 failed' if ret else 'flake8 passed') 29 | return ret 30 | 31 | 32 | def isort_main(args): 33 | print('Running isort code checking') 34 | ret = subprocess.call(['isort'] + args) 35 | 36 | if ret: 37 | print('isort failed: Some modules have incorrectly ordered imports. Fix by running `isort --recursive .`') 38 | else: 39 | print('isort passed') 40 | 41 | return ret 42 | 43 | 44 | def split_class_and_function(string): 45 | class_string, function_string = string.split('.', 1) 46 | return "%s and %s" % (class_string, function_string) 47 | 48 | 49 | def is_function(string): 50 | # `True` if it looks like a test function is included in the string. 51 | return string.startswith('test_') or '.test_' in string 52 | 53 | 54 | def is_class(string): 55 | # `True` if first character is uppercase - assume it's a class name. 56 | return string[0] == string[0].upper() 57 | 58 | 59 | if __name__ == "__main__": 60 | try: 61 | sys.argv.remove('--nolint') 62 | except ValueError: 63 | run_flake8 = True 64 | run_isort = True 65 | else: 66 | run_flake8 = False 67 | run_isort = False 68 | 69 | try: 70 | sys.argv.remove('--lintonly') 71 | except ValueError: 72 | run_tests = True 73 | else: 74 | run_tests = False 75 | 76 | try: 77 | sys.argv.remove('--fast') 78 | except ValueError: 79 | style = 'default' 80 | else: 81 | style = 'fast' 82 | run_flake8 = False 83 | run_isort = False 84 | 85 | if len(sys.argv) > 1: 86 | pytest_args = sys.argv[1:] 87 | first_arg = pytest_args[0] 88 | 89 | try: 90 | pytest_args.remove('--coverage') 91 | except ValueError: 92 | pass 93 | else: 94 | pytest_args = [ 95 | '--cov-report', 96 | 'xml', 97 | '--cov', 98 | 'jwt_knox'] + pytest_args 99 | 100 | if first_arg.startswith('-'): 101 | # `runtests.py [flags]` 102 | pytest_args = ['tests'] + pytest_args 103 | elif is_class(first_arg) and is_function(first_arg): 104 | # `runtests.py TestCase.test_function [flags]` 105 | expression = split_class_and_function(first_arg) 106 | pytest_args = ['tests', '-k', expression] + pytest_args[1:] 107 | elif is_class(first_arg) or is_function(first_arg): 108 | # `runtests.py TestCase [flags]` 109 | # `runtests.py test_function [flags]` 110 | pytest_args = ['tests', '-k', pytest_args[0]] + pytest_args[1:] 111 | else: 112 | pytest_args = PYTEST_ARGS[style] 113 | 114 | if run_tests: 115 | exit_on_failure(pytest.main(pytest_args)) 116 | 117 | if run_flake8: 118 | exit_on_failure(flake8_main(FLAKE8_ARGS)) 119 | 120 | if run_isort: 121 | exit_on_failure(isort_main(ISORT_ARGS)) 122 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import setuptools 4 | setuptools.setup(name="drf-jwt-knox") 5 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=ssaavedra_drf-jwt-knox 2 | sonar.organization = ssaavedra-github 3 | sonar.sources=jwt_knox 4 | sonar.host.url=https://sonarcloud.io 5 | sonar.python.coverage.reportPaths=./coverage.xml 6 | sonar.python.version=3.9,3.10,3.11 7 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ssaavedra/drf-jwt-knox/1017786ce7d9044fb613c845cf66393cdf585aad/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | def pytest_configure(): 2 | from django.conf import settings 3 | 4 | settings.configure( 5 | DEBUG_PROPAGATE_EXCEPTIONS=True, 6 | DATABASES={ 7 | 'default': { 8 | 'ENGINE': 'django.db.backends.sqlite3', 9 | 'NAME': ':memory:' 10 | } 11 | }, 12 | SITE_ID=1, 13 | SECRET_KEY='not very secret in tests', 14 | USE_I18N=True, 15 | STATIC_URL='/static/', 16 | ROOT_URLCONF='tests.urls', 17 | TEMPLATES=[ 18 | { 19 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 20 | 'APP_DIRS': True, 21 | }, 22 | ], 23 | MIDDLEWARE=( 24 | 'django.contrib.sessions.middleware.SessionMiddleware', 25 | 'django.middleware.common.CommonMiddleware', 26 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 27 | 'django.contrib.messages.middleware.MessageMiddleware', 28 | ), 29 | INSTALLED_APPS=( 30 | 'django.contrib.auth', 31 | 'django.contrib.contenttypes', 32 | 'django.contrib.sessions', 33 | 'django.contrib.sites', 34 | 'django.contrib.staticfiles', 35 | 'rest_framework', 36 | 'knox', 37 | 'tests', 38 | ), 39 | PASSWORD_HASHERS=( 40 | 'django.contrib.auth.hashers.MD5PasswordHasher', 41 | ), 42 | REST_FRAMEWORK={ 43 | 'DEFAULT_AUTHENTICATION_CLASSES': [ 44 | 'rest_framework.authentication.BasicAuthentication', 45 | 'rest_framework.authentication.SessionAuthentication', 46 | ], 47 | }, 48 | ) 49 | 50 | try: 51 | import django 52 | django.setup() 53 | except AttributeError: 54 | pass 55 | -------------------------------------------------------------------------------- /tests/test_jwt_knox.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import APITestCase 2 | 3 | from datetime import timedelta 4 | from django.contrib.auth.models import User 5 | from django.urls import reverse 6 | from rest_framework import status 7 | 8 | 9 | class APIAuthTest(APITestCase): 10 | 11 | # Our default user's credentials 12 | username = 'test_user' 13 | email = '' 14 | password = 'secret_password' 15 | test_logout_all_client_number = 3 16 | 17 | # Used URLs 18 | login_url = reverse('jwt_knox-get-token') 19 | verify_url = reverse('jwt_knox-verify') 20 | logout_current_url = reverse('jwt_knox-logout') 21 | logout_other_url = reverse('jwt_knox-logout-other') 22 | logout_all_url = reverse('jwt_knox-logout-all') 23 | 24 | def setUp(self): 25 | """ 26 | Before each test, run the create_test_user fixture 27 | """ 28 | self.user = self.fixture_create_test_user() 29 | 30 | def fixture_create_test_user(self): 31 | """ 32 | Adds the default user to the database 33 | :return: 34 | """ 35 | user = User.objects.create_user(username=self.username, 36 | email=self.email, 37 | password=self.password, 38 | ) 39 | 40 | return user 41 | 42 | def with_token(self, token): 43 | if not token: 44 | self.client.credentials(HTTP_AUTHORIZATION=None) 45 | return self 46 | 47 | self.client.credentials(HTTP_AUTHORIZATION=token) 48 | 49 | return self 50 | 51 | def verify_token(self, token): 52 | return self.with_token(token).client.post(self.verify_url) 53 | 54 | def logout_current(self, token=None): 55 | if token: 56 | self.with_token(token) 57 | return self.client.post(self.logout_current_url) 58 | 59 | def logout_other(self): 60 | return self.client.post(self.logout_other_url) 61 | 62 | def logout_all(self): 63 | return self.client.post(self.logout_all_url) 64 | 65 | def basic_authentication(self): 66 | from base64 import b64encode 67 | values = "{username}:{password}".format( 68 | username=self.username, 69 | password=self.password, 70 | ) 71 | b64values = b64encode(values.encode('utf-8')).decode('utf-8') 72 | auth_header = "Basic {0}".format(b64values) 73 | self.client.credentials(HTTP_AUTHORIZATION=auth_header) 74 | return self 75 | 76 | def get_token(self): 77 | # Instead of force_authenticate, we can also use basic_authentication 78 | # self.basic_authentication() 79 | self.client.force_authenticate(user=self.user) 80 | response = self.client.post(self.login_url) 81 | # Unset authentication after our request 82 | self.client.force_authenticate() 83 | return response 84 | 85 | def get_n_tokens(self, n): 86 | """ 87 | This method allows to get an arbitrary number of tokens for a 88 | logged-in client 89 | :param n: Number of tokens we want to get 90 | :return: 91 | """ 92 | response_list = [] 93 | self.basic_authentication() 94 | self.client.force_authenticate(user=self.user) 95 | for i in range(0, n): 96 | response = self.client.post(self.login_url) 97 | self.assertEqual(response.status_code, status.HTTP_200_OK) 98 | response_list.append(response) 99 | # Unset authentication after our request 100 | self.client.force_authenticate() 101 | return response_list 102 | 103 | def test_authentication_bad_token_info(self): 104 | """ 105 | Verify that a valid JWT without the token jti information 106 | will not authorize 107 | """ 108 | from jwt_knox.utils import ( 109 | jwt_encode_handler, 110 | jwt_join_header_and_token, 111 | jwt_payload_handler 112 | ) 113 | token = jwt_join_header_and_token(jwt_encode_handler( 114 | jwt_payload_handler(self.user, None, timedelta(10)))) 115 | response = self.with_token(token).logout_current() 116 | self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 117 | 118 | def test_authentication_user_inactive(self): 119 | """ 120 | Disallow login with a token for a user that has is_active=False 121 | """ 122 | login_response = self.get_token() 123 | token = login_response.data['token'] 124 | self.user.is_active = False 125 | self.user.save() 126 | response = self.verify_token(token) 127 | self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 128 | 129 | def test_authentication_user_removed(self): 130 | """ 131 | Disallow login gracefully with a token for a user that is no 132 | longer in the DB 133 | """ 134 | login_response = self.get_token() 135 | token = login_response.data['token'] 136 | self.user.delete() 137 | response = self.verify_token(token) 138 | self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 139 | 140 | def test_get_token(self): 141 | """ 142 | Verify that our 'get_token' endpoint is working properly 143 | :return: 144 | """ 145 | response = self.get_token() 146 | self.assertEqual(response.status_code, status.HTTP_200_OK) 147 | 148 | def test_logout(self): 149 | """ 150 | Verify that our 'logout' endpoint is logging out properly 151 | :return: 152 | """ 153 | login_response = self.get_token() 154 | token = login_response.data['token'] 155 | logout_response = self.with_token(token).logout_current() 156 | self.assertEqual(logout_response.status_code, 157 | status.HTTP_204_NO_CONTENT) 158 | 159 | def test_double_login(self): 160 | """ 161 | This test proves that if a user can log in twice and the two 162 | tokens he will be provided are different 163 | :return: 164 | """ 165 | # Warning, in the next few lines we are using 'magic numbers' 166 | response_list = self.get_n_tokens(2) 167 | token1 = response_list[0].data['token'] 168 | token2 = response_list[1].data['token'] 169 | self.assertEqual(response_list[0].status_code, status.HTTP_200_OK) 170 | self.assertEqual(response_list[1].status_code, status.HTTP_200_OK) 171 | self.assertNotEqual(token1, token2) 172 | 173 | def test_double_logout(self): 174 | """ 175 | Here we prove that after logging out once, the endpoint will 176 | reject a second logout request 177 | :return: 178 | """ 179 | login_response = self.get_token() 180 | self.assertEqual(login_response.status_code, status.HTTP_200_OK) 181 | token = login_response.data['token'] 182 | logout_response1 = self.with_token(token).logout_current() 183 | logout_response2 = self.with_token(token).logout_current() 184 | self.assertEqual(logout_response1.status_code, 185 | status.HTTP_204_NO_CONTENT) 186 | self.assertEqual(logout_response2.status_code, 187 | status.HTTP_401_UNAUTHORIZED) 188 | 189 | def test_verify_old_token(self): 190 | """ 191 | This test verifies that after logging out, the token we used 192 | becomes invalid 193 | :return: 194 | """ 195 | login_response = self.get_token() 196 | token = login_response.data['token'] 197 | logout_response = self.with_token(token).logout_current() 198 | verify_response = self.verify_token(token) 199 | self.assertEqual(verify_response.status_code, 200 | status.HTTP_401_UNAUTHORIZED) 201 | 202 | def test_logout_non_logged(self): 203 | """ 204 | This test verifies that the "logout" endpoint will reject 205 | request from non-logged clients 206 | :return: 207 | """ 208 | response = self.logout_current() 209 | self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 210 | 211 | def test_logout_old_token(self): 212 | """ 213 | Here we prove that a client can't use an expired token to logout even 214 | if logged in again with a different one 215 | 216 | :return: 217 | """ 218 | token1 = self.get_token().data['token'] 219 | self.with_token(token1).logout_current() 220 | token2 = self.get_token().data['token'] 221 | self.assertNotEqual(token1, token2) 222 | response2 = self.with_token(token1).logout_current() 223 | self.assertEqual(response2.status_code, status.HTTP_401_UNAUTHORIZED) 224 | 225 | # We can eventually go on and verify if we're still logged 226 | # and if we can finally log out using the second token 227 | response3 = self.with_token(token2).logout_current() 228 | self.assertEqual(response3.status_code, status.HTTP_204_NO_CONTENT) 229 | 230 | def test_verify(self): 231 | """ 232 | A simple test against or 'verify' endpoint 233 | :return: 234 | """ 235 | token_response = self.get_token() 236 | token = token_response.data['token'] 237 | response = self.verify_token(token) 238 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 239 | 240 | def test_close_other_access(self): 241 | """ 242 | During this test a client obtains 2 tokens and we prove that 243 | authenticating with just one of them he's able to invalidate the 244 | other one using the 'logout_other' endpoint 245 | :return: 246 | """ 247 | # Warning, in the next 3 lines we're using 'magic numbers' 248 | response_list = self.get_n_tokens(2) 249 | token1 = response_list[0].data['token'] 250 | token2 = response_list[1].data['token'] 251 | response3 = self.with_token(token1).logout_other() 252 | self.assertEqual(response3.status_code, status.HTTP_200_OK) 253 | response4 = self.verify_token(token1) 254 | self.assertEqual(response4.status_code, status.HTTP_204_NO_CONTENT) 255 | response5 = self.verify_token(token2) 256 | self.assertEqual(response5.status_code, status.HTTP_401_UNAUTHORIZED) 257 | 258 | def test_logout_all(self): 259 | """ 260 | In this test we prove that a single request against the 'logout_all' 261 | endpoint will invalidate all the tokens related to a single user 262 | :return: 263 | """ 264 | token_list = [] 265 | response_list = self.get_n_tokens(self.test_logout_all_client_number) 266 | for i in range(0, self.test_logout_all_client_number): 267 | token = response_list[i].data['token'] 268 | token_list.append(token) 269 | logout_response = self.with_token(token_list[0]).logout_all() 270 | self.assertEqual(logout_response.status_code, 271 | status.HTTP_204_NO_CONTENT) 272 | for i in range(0, self.test_logout_all_client_number): 273 | response = self.verify_token(token_list[i]) 274 | self.assertEqual(response.status_code, 275 | status.HTTP_401_UNAUTHORIZED) 276 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from jwt_knox.urls import urlpatterns 2 | 3 | 4 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts=--tb=short 3 | 4 | [tox] 5 | isolated_build = true 6 | envlist= 7 | # {py37,py38}-django32lts 8 | {py39,py310,py311}-django41 9 | {py39,py310}-django42lts 10 | py311-djangomaster 11 | 12 | [testenv] 13 | allowlist_externals = 14 | ./runtests.py 15 | poetry 16 | 17 | commands_pre = 18 | poetry install --no-root --sync 19 | 20 | commands = poetry run ./runtests.py --fast {posargs} --coverage -rw 21 | setenv = 22 | PYTHONDONTWRITEBYTECODE=1 23 | PYTHONWARNINGS=once 24 | 25 | deps = 26 | django32lts: -rrequirements/requirements-django32lts.txt 27 | django41: -rrequirements/requirements-django41.txt 28 | django42lts: -rrequirements/requirements-django42lts.txt 29 | djangomaster: https://github.com/django/django/archive/master.tar.gz 30 | -rrequirements/requirements-testing.txt 31 | 32 | basepython = 33 | py37: python3.7 34 | py38: python3.8 35 | py39: python3.9 36 | py310: python3.10 37 | py311: python3.11 38 | 39 | package = wheel 40 | require_locked_deps = true 41 | poetry_dep_groups = 42 | dev 43 | --------------------------------------------------------------------------------