├── .coveragerc_dj22 ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── discussion.md │ └── feature_request.md ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── ci └── azure-pipelines.yml ├── docs ├── guide.md ├── index.md ├── security.md └── upgrade │ ├── 1.0.md │ ├── 1.4.md │ ├── 2.0.md │ └── 3.0.md ├── mkdocs.yml ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── setup.py ├── src └── rest_framework_api_key │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── crypto.py │ ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20190529_2243.py │ ├── 0003_auto_20190623_1952.py │ ├── 0004_prefix_hashed_key.py │ ├── 0005_auto_20220110_1102.py │ └── __init__.py │ ├── models.py │ ├── permissions.py │ └── py.typed ├── test_project ├── README.md ├── __init__.py ├── heroes │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_prefix_hashed_key.py │ │ ├── 0003_alter_hero_id.py │ │ ├── 0004_auto_20220110_1102.py │ │ └── __init__.py │ ├── models.py │ └── permissions.py ├── manage.py └── project │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ ├── views.py │ └── wsgi.py ├── tests ├── __init__.py ├── conftest.py ├── dateutils.py ├── test_admin.py ├── test_checks.py ├── test_compatibility.py ├── test_hashers.py ├── test_legacy_key_generator.py ├── test_migrations.py ├── test_model.py ├── test_permissions.py ├── test_permissions_combination.py ├── test_permissions_custom.py └── test_test_project.py └── tools ├── install_django.sh └── makemigrations.py /.coveragerc_dj22: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: no cover 4 | pragma: nodj22 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. Linux] 25 | - Version: [e.g. 0.2.2] 26 | - Python Version: [e.g. 3.7] 27 | - Django Version: [e.g. 2.1] 28 | - DRF Version: [e.g. 3.8.2] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/discussion.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Discussion/Other 3 | about: Discuss something that is not a bug report or a feature request. 4 | 5 | --- 6 | 7 | It's free form! Just start typing. 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 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 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # databases 107 | *.sqlite3 108 | 109 | .idea/ 110 | .ruff_cache/ 111 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## 3.1.0 - 2025-04-04 8 | 9 | ### Added 10 | 11 | - Add official support for Python 3.12 and 3.13, and Django 5.0 up to 5.2, accomodating changes to `USE_TZ`. (Pull #266) 12 | 13 | ## 3.0.0 - 2023-09-30 14 | 15 | ### Changed 16 | 17 | - Use faster SHA512-based key hasher instead of password hashers. Reduces server load by making API key validation orders of magnitude faster (10x to 30x according to estimations, network latency aside). Hashed key will be transparently upgraded the first time `.is_valid()` is called. (Pull #244, Pull #251) 18 | 19 | ### Removed 20 | 21 | - Dropped support for Python 3.7, which has reached EOL. (Pull #247) 22 | - Drop redundant `.has_object_permission()` implementation on `BaseHasAPIKey` when using DRF 3.14.0 or above. (Pull #240) 23 | 24 | ### Added 25 | 26 | - Add official support for Python 3.11. (Pull #247) 27 | 28 | ## 2.3.0 - 2023-01-19 29 | 30 | ### Removed 31 | 32 | - Drop support for Python 3.6, which has reached EOL. (Pull #210) 33 | 34 | ### Fixed 35 | 36 | - Fix migration 0004 when run against a non default database. (Pull #215) 37 | 38 | ## 2.2.0 - 2022-03-11 39 | 40 | ### Added 41 | 42 | - Added support for Django config detection for different versions (PR #187) 43 | 44 | ### Changed 45 | 46 | - Add official support for Django 3.2 and Python 3.9 and 3.10 (PR #189) 47 | - Bumped `hashed_key` field's `max_length` from 100 to 150 to address length issue with `argon2-cffi` (PR #193) 48 | 49 | ## 2.1.0 - 2021-09-24 50 | 51 | ### Added 52 | 53 | - Add support for custom API `keyword`. (Pull #175) 54 | 55 | ## 2.0.0 - 2020-04-07 56 | 57 | **NOTE**: this release drops compatibility with certain Python and Django versions, but contains no other breaking changes. See [Upgrade to 2.0](https://florimondmanca.github.io/djangorestframework-api-key/upgrade/2.0/) for detailed migration steps. 58 | 59 | ### Removed 60 | 61 | - Dropped support for Django 2.0 and Django 2.1. (Pull #126) 62 | - Dropped support for Python 3.5. (Pull #84) 63 | 64 | ### Added 65 | 66 | - Add support for Django 3.0. (Pull #82) 67 | - Add support for Python 3.8. (Pull #81) 68 | - Add `BaseAPIKeyManager.get_from_key()` to allow retrieving API keys from views. (Pull #93) 69 | - Add type annotations, and partial support for `django-stubs` and `djangorestframework-stubs`. (Pull #88, Pull #122) 70 | 71 | ## 1.4.1 - 2019-08-24 72 | 73 | ### Added 74 | 75 | - Now ships with type annotations ([PEP 561](https://www.python.org/dev/peps/pep-0561/)). (Pull #73) 76 | 77 | ## 1.4.0 - 2019-07-16 78 | 79 | **NOTE**: this release contains migrations. See [Upgrade to v1.4](https://florimondmanca.github.io/djangorestframework-api-key/upgrade/1.4/) for detailed instructions. 80 | 81 | ### Added 82 | 83 | - The `prefix` and `hashed_key` are now stored in dedicated fields on the `APIKey` model. (Pull #62) 84 | 85 | ## 1.3.0 - 2019-06-28 86 | 87 | **NOTE**: this release contains migrations. In your Django project, run them using: 88 | 89 | ```python 90 | python manage.py migrate rest_framework_api_key 91 | ``` 92 | 93 | ### Added 94 | 95 | - Add abstract API key model (`AbstractAPIKey`) and base manager (`BaseAPIKeyManager`). (Pull #36) 96 | - Add base permissions (`BaseHasAPIKey`). (Pull #46) 97 | 98 | ### Changed 99 | 100 | - The `id` field of `APIKey` is now non-`editable`. 101 | - `APIKeyModelAdmin` does not define `fieldsets` anymore. This allows subclasses to benefit from Django's automatic fieldsets. (Pull #52) 102 | 103 | ### Fixed 104 | 105 | - Explicitly use `utf-8` encoding in `setup.py`, which could previously lead to issues when installing on certain systems. (Pull #58) 106 | 107 | ## 1.2.1 - 2019-06-03 108 | 109 | ### Fixed 110 | 111 | - Fixed a critical bug in `APIKeyModelAdmin` that prevented `rest_framework_api_key` from passing Django system checks. (Pull #39) 112 | 113 | ## 1.2.0 - 2019-05-29 114 | 115 | **NOTE**: this release contains migrations. In your Django project, run them using: 116 | 117 | ```python 118 | python manage.py migrate rest_framework_api_key 119 | ``` 120 | 121 | ### Added 122 | 123 | - API keys can now have an optional `expiry_date`. (Pull #33) `HasAPIKey` denies access if the API key has expired, i.e. if `expiry_date`, if set, is in the past. 124 | - It is now possible to search by `prefix` in the API key admin panel. 125 | - The `prefix` is now displayed in the edit view of the API key admin panel. 126 | 127 | ## 1.1.0 - 2019-05-14 128 | 129 | ### Added 130 | 131 | - Improve documentation on which password hasher is used. 132 | - Add tests against the Argon2, BcryptSHA256 and PBKDF2SHA1 hashers. (Pull #32) 133 | 134 | ### Fixed 135 | 136 | - Fix support for password hashers that generate hashes that contain dots. (Pull #31) 137 | 138 | ## 1.0.0 - 2019-04-24 139 | 140 | **This release is incompatible with 0.x**. See [Upgrade to 1.0](https://florimondmanca.github.io/djangorestframework-api-key/upgrade/1.4/) for migration steps. 141 | 142 | ### Removed 143 | 144 | - Remove `HasAPIKeyOrIsAuthenticated` permission class. You should use bitwise composition now, e.g. `HasAPIKey | IsAuthenticated`. 145 | - Drop the `DRF_API_KEY_*` settings. (Pull #19) 146 | 147 | ### Changed 148 | 149 | - Switch to a new API key generation and validation scheme. Clients must now authorize using a single API key header (Pull #19). The header is `Authorization` by default. It can be customized using the `API_KEY_CUSTOM_HEADER` setting (Pull #26). Use the `name` field to identify clients. 150 | 151 | ### Added 152 | 153 | - Add support for Django 2.2. (Pull #27) 154 | - Add programmatic API key creation using `APIKey.objects.create_key()`. (Pull #19) 155 | 156 | ### Fixed 157 | 158 | - Improved API key storage using Django's password hashing helpers. (Uses the default Django password hasher.) (Pull #19) 159 | 160 | ## 0.4.0 - 2019-04-21 161 | 162 | ### Removed 163 | 164 | - Drop support for Python 3.4. Only 3.5, 3.6 and 3.7 are supported now. 165 | - Drop support for Django < 2.0. Only 2.0 and 2.1 are supported now. 166 | 167 | ### Fixed 168 | 169 | - `HasAPIKey` now implements `.has_object_permissions()`, which allows to compose it with other permission classes and perform object-level permission checks. (Pull #25) 170 | 171 | ## 0.3.1 - 2018-11-17 172 | 173 | _Initial changelog entry._ 174 | 175 | ### Added 176 | 177 | - `APIKey` model. 178 | - `HasAPIKey` and `HasAPIKeyOrIsAuthenticated` permission classes. 179 | - Generate, view and revoke API keys from the Django admin. 180 | - Authenticate requests using the `Api-Token` and `Api-Secret-Key` headers. Customizable via the `DRF_API_KEY_TOKEN_HEADER` and `DRF_API_KEY_SECRET_KEY_HEADER` settings. 181 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in contributing to this project! 4 | 5 | Here are a few ways in which you can help: 6 | 7 | - Discovered a bug? Please open a [bug report](https://github.com/florimondmanca/djangorestframework-api-key/issues/new?template=bug_report.md). 8 | - Have a feature you'd like to see implemented? Please open a [Feature Request](https://github.com/florimondmanca/djangorestframework-api-key/issues/new?template=feature_request.md). 9 | - For any other contribution, please open a [discussion](https://github.com/florimondmanca/djangorestframework-api-key/issues/new?template=discussion.md). 10 | 11 | **NOTE**: for **non-trivial changes** we _highly_ encourage you to **open an issue** first. This will allow maintainers and contributors to confirm that the problem you are trying to solve is well-posed, in the scope of the project, and/or can't be solved with existing features. 12 | 13 | ### Installation 14 | 15 | 1. Fork the repository. 16 | 1. Clone it on your machine. 17 | 1. Install dependencies: 18 | 19 | ``` 20 | make install 21 | ``` 22 | 23 | ### Tests 24 | 25 | Run the tests using: 26 | 27 | ``` 28 | make test 29 | ``` 30 | 31 | ### Code style 32 | 33 | Run code auto-formatting with: 34 | 35 | ``` 36 | make format 37 | ``` 38 | 39 | Run code style checks using: 40 | 41 | ``` 42 | make check 43 | ``` 44 | 45 | ### Generating migrations 46 | 47 | This package includes migrations. To update them in case of changes without setting up a Django project, run: 48 | 49 | ``` 50 | make migrations 51 | ``` 52 | 53 | ### Documentation 54 | 55 | Build the documentation using: 56 | 57 | ``` 58 | make docs 59 | ``` 60 | 61 | Serve the docs site locally (with hot-reload) using: 62 | 63 | ``` 64 | make docs-serve 65 | ``` 66 | 67 | ## Notes to maintainers 68 | 69 | ### Releasing 70 | 71 | - Create a release PR with the following: 72 | - Bump the package version by editing `__version__.py`. 73 | - Update the changelog with any relevant PRs merged since the last version: bug fixes, new features, changes, deprecations, removals. 74 | - Once the release PR is merged, create a [new release](https://github.com/florimondmanca/djangorestframework-api-key/releases/new), including: 75 | - Tag version, like `2.1.0`. 76 | - Release title, `Version 2.1.0`. 77 | - Description copied from the changelog. 78 | - Once created, this release will be automatically uploaded to PyPI via a publish job on Azure Pipelines. 79 | - Deploy the docs using: `make docs-deploy` 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Florimond Manca 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft src 2 | include README.md 3 | include CHANGELOG.md 4 | include LICENSE 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: docs 2 | 3 | venv = venv 4 | bin = ${venv}/bin/ 5 | pysources = src/ test_project/ tests/ 6 | 7 | build: 8 | ${bin}python -m build 9 | 10 | check: 11 | ${bin}ruff check ${pysources} 12 | ${bin}black --check --diff ${pysources} 13 | ${bin}mypy ${pysources} 14 | make migrations-check 15 | 16 | docs: 17 | ${bin}mkdocs build 18 | 19 | docs-serve: 20 | ${bin}mkdocs serve 21 | 22 | docs-deploy: 23 | ${bin}mkdocs gh-deploy 24 | 25 | install: install-python 26 | 27 | venv: 28 | python3 -m venv ${venv} 29 | 30 | install-python: venv 31 | ${bin}pip install -U pip wheel 32 | ${bin}pip install -U build 33 | ${bin}pip install -r requirements.txt 34 | ./tools/install_django.sh ${bin}pip 35 | 36 | format: 37 | ${bin}ruff check --fix ${pysources} 38 | ${bin}black ${pysources} 39 | 40 | migrations: 41 | ${bin}python -m tools.makemigrations 42 | 43 | migrations-check: 44 | ${bin}python -m tools.makemigrations --check 45 | 46 | publish: 47 | ${bin}twine upload dist/* 48 | 49 | test: 50 | ${bin}pytest 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django REST Framework API Key 2 | 3 | API key permissions for the [Django REST Framework](https://www.django-rest-framework.org). 4 | 5 |
6 | 7 | build status 8 | 9 | 10 | coverage 11 | 12 | 13 | package version 14 | 15 |
16 |
17 | python versions 18 | django versions 19 | drf versions 20 |
21 | 22 | ## Introduction 23 | 24 | **Django REST Framework API Key is a library for allowing server-side clients to safely use your API.** These clients are typically third-party backends and services (i.e. _machines_) which do not have a user account but still need to interact with your API in a secure way. 25 | 26 | ### Features 27 | 28 | - ✌️ **Simple to use**: create, view and revoke API keys via the admin site, or use built-in helpers to create API keys programmatically. 29 | - 🔒 **As secure as possible**: API keys are treated with the same level of care as user passwords. They are only visible at creation and hashed before storing in the database. 30 | - 🎨 **Customizable**: satisfy specific business requirements by building your own customized API key models, permission classes and admin panels. 31 | 32 | ### Should I use API keys? 33 | 34 | There are important security aspects you need to consider before switching to an API key access control scheme. We've listed some of these in [Security caveats](docs/security.md#caveats), including serving your API over HTTPS. 35 | 36 | Besides, see [Why and when to use API keys](https://cloud.google.com/endpoints/docs/openapi/when-why-api-key#top_of_page) for hints on whether API keys can fit your use case. 37 | 38 | API keys are ideal in the following situations: 39 | 40 | - Blocking anonymous traffic. 41 | - Implementing API key-based [throttling](https://www.django-rest-framework.org/api-guide/throttling/). (Note that Django REST Framework already has may built-in utilities for this use case.) 42 | - Identifying usage patterns by logging request information along with the API key. 43 | 44 | They can also present enough security for authorizing internal services, such as your API server and an internal frontend application. 45 | 46 | > Please note that this package is NOT meant for authentication. You should NOT use this package to identify individual users, either directly or indirectly. 47 | > 48 | > If you need server-to-server authentication, you may want to consider OAuth instead. Libraries such as [django-oauth-toolkit](https://django-oauth-toolkit.readthedocs.io/en/latest/index.html) can help. 49 | 50 | ## Quickstart 51 | 52 | Install with `pip`: 53 | 54 | ```bash 55 | pip install "djangorestframework-api-key==3.*" 56 | ``` 57 | 58 | _**Note**: It is highly recommended to **pin your dependency** to the latest major version (as depicted above), as breaking changes may and will happen between major releases._ 59 | 60 | Add the app to your `INSTALLED_APPS`: 61 | 62 | ```python 63 | # settings.py 64 | 65 | INSTALLED_APPS = [ 66 | # ... 67 | "rest_framework", 68 | "rest_framework_api_key", 69 | ] 70 | ``` 71 | 72 | Run the included migrations: 73 | 74 | ```bash 75 | python manage.py migrate 76 | ``` 77 | 78 | To learn how to configure permissions and manage API keys, head to the [Documentation](https://florimondmanca.github.io/djangorestframework-api-key). 79 | 80 | ## Changelog 81 | 82 | See [CHANGELOG.md](https://github.com/florimondmanca/djangorestframework-api-key/tree/master/CHANGELOG.md). 83 | 84 | ## Contributing 85 | 86 | See [CONTRIBUTING.md](https://github.com/florimondmanca/djangorestframework-api-key/tree/master/CONTRIBUTING.md). 87 | 88 | ## License 89 | 90 | MIT 91 | -------------------------------------------------------------------------------- /ci/azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | resources: 2 | repositories: 3 | - repository: templates 4 | type: github 5 | endpoint: github 6 | name: florimondmanca/azure-pipelines-templates 7 | ref: refs/tags/6.3 8 | containers: 9 | - container: pg15 10 | image: postgres:15-alpine 11 | ports: 12 | - 5432:5432 13 | env: 14 | POSTGRES_USER: postgres 15 | POSTGRES_PASSWORD: postgres 16 | POSTGRES_DB: default 17 | 18 | trigger: 19 | - master 20 | - refs/tags/* 21 | 22 | pr: 23 | - master 24 | 25 | variables: 26 | - name: CI 27 | value: "true" 28 | - name: PIP_CACHE_DIR 29 | value: $(Pipeline.Workspace)/.cache/pip 30 | - group: pypi-credentials 31 | 32 | stages: 33 | - stage: test 34 | jobs: 35 | - template: job--python-check.yml@templates 36 | parameters: 37 | pythonVersion: "3.13" 38 | 39 | - template: job--python-docs-build.yml@templates 40 | parameters: 41 | pythonVersion: "3.13" 42 | 43 | - template: job--python-test.yml@templates 44 | parameters: 45 | jobs: 46 | py38_dj22: 47 | variables: 48 | DJANGO_VERSION: "2.2.*" 49 | PYTEST_ADDOPTS: "--cov-config=.coveragerc_dj22" 50 | 51 | py38_dj32: 52 | coverage: true 53 | variables: 54 | DJANGO_VERSION: "3.2.*" 55 | 56 | py311_dj42: 57 | coverage: true 58 | variables: 59 | DJANGO_VERSION: "4.2.*" 60 | PYTEST_ADDOPTS: "--cov=tests" 61 | 62 | py313_dj52: 63 | coverage: true 64 | variables: 65 | DJANGO_VERSION: "5.2.*" 66 | PYTEST_ADDOPTS: "--cov=tests" 67 | 68 | py313_postgres: 69 | services: 70 | postgres: pg15 71 | variables: 72 | DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/default" 73 | 74 | - stage: publish 75 | condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/') 76 | jobs: 77 | - template: job--python-publish.yml@templates 78 | parameters: 79 | token: $(pypiToken) 80 | pythonVersion: "3.13" 81 | -------------------------------------------------------------------------------- /docs/guide.md: -------------------------------------------------------------------------------- 1 | # User Guide 2 | 3 | ## Getting started 4 | 5 | ### Installation 6 | 7 | Install with `pip`: 8 | 9 | ```bash 10 | pip install "djangorestframework-api-key==3.*" 11 | ``` 12 | 13 | _**Note**: It is highly recommended to **pin your dependency** to the latest major version (as depicted above), as breaking changes may and will happen between major releases._ 14 | 15 | ### Project setup 16 | 17 | Add the app to your `INSTALLED_APPS`: 18 | 19 | ```py 20 | # settings.py 21 | 22 | INSTALLED_APPS = [ 23 | # ... 24 | "rest_framework", 25 | "rest_framework_api_key", 26 | ] 27 | ``` 28 | 29 | Run the included migrations: 30 | 31 | ```bash 32 | python manage.py migrate 33 | ``` 34 | 35 | ### Setting permissions 36 | 37 | The `HasAPIKey` permission class protects a view behind API key authorization. 38 | 39 | You can set the permission globally: 40 | 41 | ```python 42 | # settings.py 43 | REST_FRAMEWORK = { 44 | "DEFAULT_PERMISSION_CLASSES": [ 45 | "rest_framework_api_key.permissions.HasAPIKey", 46 | ] 47 | } 48 | ``` 49 | 50 | or on a per-view basis: 51 | 52 | ```python 53 | # views.py 54 | from rest_framework.views import APIView 55 | from rest_framework_api_key.permissions import HasAPIKey 56 | 57 | class UserListView(APIView): 58 | permission_classes = [HasAPIKey] 59 | # ... 60 | ``` 61 | 62 | See also [Setting the permission policy](http://www.django-rest-framework.org/api-guide/permissions/#setting-the-permission-policy) for more information on using permission classes in the Django REST Framework. 63 | 64 | !!! tip 65 | You can use the bitwise operators `|` and `&` to compose `HasAPIKey` with other permission classes and achieve more complex authorization behaviour. 66 | 67 | For example, to require a valid API key _or_ authentication credentials, use: 68 | 69 | ```python 70 | from rest_framework.permissions import IsAuthenticated 71 | from rest_framework_api_key.permissions import HasAPIKey 72 | # ... 73 | permission_classes = [HasAPIKey | IsAuthenticated] 74 | ``` 75 | 76 | ### Manually validating API keys 77 | You can also manually validate an API key with the `APIKey` objects manager using the `is_valid()` method on the manager lke below. This is useful for validating API keys outside of a normal Django view, such as inside a websocket consumer from Django Channels. 78 | 79 | ```python 80 | from rest_framework_api_key.permissions import APIKey 81 | 82 | # this should be a string containing only the API key - remove any additional text like "Api-Key" if present 83 | raw_key = "XXXXXXXX.XXXXXXXXXX" 84 | is_valid_key = APIKey.objects.is_valid(raw_key) 85 | ``` 86 | 87 | ### Making authorized requests 88 | 89 | #### Authorization header 90 | 91 | By default, clients must pass their API key via the `Authorization` header. It must be formatted as follows: 92 | 93 | ``` 94 | Authorization: Api-Key 95 | ``` 96 | 97 | where `` refers to the full generated API key (see [Creating and managing API keys](#creating-and-managing-api-keys) below). 98 | 99 | To know under which conditions access is granted, please see [Grant scheme](security.md#grant-scheme). 100 | 101 | If wanting to also customize the keyword used for parsing the Api-Key, please see [API key Custom Keyword](guide.md#api-key-custom-keyword) 102 | 103 | #### Custom header 104 | 105 | You can set the `API_KEY_CUSTOM_HEADER` setting to a non-`None` value to require clients to pass their API key in a custom header instead of the `Authorization` header. 106 | 107 | This is useful if you plan to use API keys _AND_ an authentication scheme which already uses the `Authorization` header (e.g. token-based authentication). 108 | 109 | For example, if you set: 110 | 111 | ```python 112 | # settings.py 113 | API_KEY_CUSTOM_HEADER = "HTTP_X_API_KEY" 114 | ``` 115 | 116 | then clients must make authorized requests using: 117 | 118 | ``` 119 | X-Api-Key: 120 | ``` 121 | 122 | where `` refers to the full generated API key. 123 | 124 | Please refer to [HttpRequest.META](https://docs.djangoproject.com/en/2.2/ref/request-response/#django.http.HttpRequest.META) for more information on headers in Django. 125 | 126 | ### Creating and managing API keys 127 | 128 | #### Admin site 129 | 130 | When it is installed, `djangorestframework-api-key` adds an "API Key Permissions" section to the Django admin site where you can create, view and revoke API keys. 131 | 132 | !!! note 133 | Upon creating an API key from the admin, the full API key is shown only once in a success message banner. **This is what should be passed in authorization headers.** After creation, only the prefix of the API key is shown in the admin site, mostly for identification purposes. If you lose the full API key, you'll need to regenerate a new one. 134 | 135 | #### Programmatic usage 136 | 137 | API keys can be created, viewed, revoked, and validated programmatically by manipulating the `APIKey` model. 138 | 139 | - You can validate API keys manually, see the "Manually validating API keys" section above. 140 | 141 | !!! note 142 | The examples below use the [Django shell](https://docs.djangoproject.com/en/2.2/ref/django-admin/#django-admin-shell). 143 | 144 | - You can view and query `APIKey` like any other model. For example, to know the total number of API keys: 145 | 146 | ```python 147 | >>> from rest_framework_api_key.models import APIKey 148 | >>> APIKey.objects.count() 149 | 42 150 | ``` 151 | 152 | - If you wish to create an API key programmatically, you'll most likely want a one-time access to its generated key too. To do so, use the `.create_key()` method on the `APIKey` objects manager instead of `.create()`: 153 | 154 | ```python 155 | >>> from rest_framework_api_key.models import APIKey 156 | >>> api_key, key = APIKey.objects.create_key(name="my-remote-service") 157 | >>> # Proceed with `api_key` and `key`... 158 | ``` 159 | 160 | !!! danger 161 | To prevent leaking API keys, you must only give the `key` **to the client that triggered its generation**. In particular, **do not keep any trace of it on the server**. 162 | 163 | - To retrieve an `APIKey` instance based on its generated key (which is not stored in the database) use the `.get_from_key()` method on the `APIKey` objects manager instead of `.get()`. This is useful if you'd like to access an `APIKey` object from a view protected by a `HasAPIKey` permission. 164 | 165 | ```python 166 | from rest_framework.views import APIView 167 | from rest_framework_api_key.models import APIKey 168 | from rest_framework_api_key.permissions import HasAPIKey 169 | 170 | from .models import Project 171 | 172 | class ProjectListView(APIView): 173 | permission_classes = [HasAPIKey] 174 | 175 | def get(self, request): 176 | """Retrieve a project based on the request API key.""" 177 | key = request.META["HTTP_AUTHORIZATION"].split()[1] 178 | api_key = APIKey.objects.get_from_key(key) 179 | project = Project.objects.get(api_key=api_key) 180 | ``` 181 | 182 | ## Customization 183 | 184 | This package provides various customization APIs that allow you to extend its basic behavior. 185 | 186 | ### API key models 187 | 188 | If the built-in `APIKey` model doesn't fit your needs, you can create your own by subclassing `AbstractAPIKey`. This is particularly useful if you need to **store extra information** or **link API keys to other models** using a `ForeignKey` or a `ManyToManyField`. 189 | 190 | !!! warning 191 | Associating API keys to users, directly or indirectly, can present a security risk. See also: [Should I use API keys?](https://florimondmanca.github.io/djangorestframework-api-key/#should-i-use-api-keys). 192 | 193 | #### Example 194 | 195 | Here's how you could link API keys to an imaginary `Organization` model: 196 | 197 | ```python 198 | # organizations/models.py 199 | from django.db import models 200 | from rest_framework_api_key.models import AbstractAPIKey 201 | 202 | class Organization(models.Model): 203 | name = models.CharField(max_length=128) 204 | active = models.BooleanField(default=True) 205 | 206 | class OrganizationAPIKey(AbstractAPIKey): 207 | organization = models.ForeignKey( 208 | Organization, 209 | on_delete=models.CASCADE, 210 | related_name="api_keys", 211 | ) 212 | ``` 213 | 214 | If you need to customize the model's `Meta`, it should inherit from `AbstractAPIKey.Meta`: 215 | 216 | ```python 217 | class OrganizationAPIKey(AbstractAPIKey): 218 | # ... 219 | class Meta(AbstractAPIKey.Meta): 220 | verbose_name = "Organization API key" 221 | verbose_name_plural = "Organization API keys" 222 | ``` 223 | 224 | #### Migrations 225 | 226 | Because `AbstractAPIKey` is an [abstract model](https://docs.djangoproject.com/en/2.2/topics/db/models/#abstract-base-classes), the custom API key model will have its own table in the database. 227 | 228 | This means that you need to **generate a migration** and then **apply it** to be able to query the new API key model: 229 | 230 | ```bash 231 | python manage.py makemigrations 232 | python manage.py migrate 233 | ``` 234 | 235 | !!! important 236 | If `AbstractAPIKey` changes (e.g. because of an update to Django REST Framework API Key), you will need to **generate and apply migrations again** to account for these changes. 237 | 238 | #### Managers 239 | 240 | The `APIKey` model as well as custom API keys models inherited from `AbstractAPIKey` have a dedicated [manager](https://docs.djangoproject.com/en/2.2/topics/db/managers) which is responsible for implementing `.create_key()` and other important behavior. 241 | 242 | As a result, if you want to build a custom API key manager, it should inherit from `BaseAPIKeyManager` instead of Django's `Manager`. 243 | 244 | Besides [customization APIs that come with Django's managers](https://docs.djangoproject.com/en/2.2/topics/db/managers/#custom-managers), `BaseAPIKeyManager` gives you one extra hook: you can override `.get_usable_keys()` to customize which set of API keys clients can use in authorized requests. 245 | 246 | For example, here's how to restrict usable keys to those of active organizations only: 247 | 248 | ```python 249 | class OrganizationAPIKeyManager(BaseAPIKeyManager): 250 | def get_usable_keys(self): 251 | return super().get_usable_keys().filter(organization__active=True) 252 | ``` 253 | 254 | !!! check 255 | Note the call to the parent implementation using `super()` here. This is because `.get_usable_keys()` has some default behavior, including making sure that revoked API keys cannot be used. 256 | 257 | !!! tip 258 | You don't need to use a custom model to use a custom manager — it can be used on the built-in `APIKey` model as well. 259 | 260 | #### Admin panel 261 | 262 | If you'd like to view and manage your custom API key model via the [Django admin site](https://docs.djangoproject.com/en/2.2/ref/contrib/admin/), you can create and register a subclass of `APIKeyModelAdmin`: 263 | 264 | ```python 265 | # organizations/admin.py 266 | from django.contrib import admin 267 | from rest_framework_api_key.admin import APIKeyModelAdmin 268 | from .models import OrganizationAPIKey 269 | 270 | @admin.register(OrganizationAPIKey) 271 | class OrganizationAPIKeyModelAdmin(APIKeyModelAdmin): 272 | pass 273 | ``` 274 | 275 | You can also customize any of the default attributes defined in `APIKeyModelAdmin`. For example, to display the organization's name in the list view, and allow searching `OrganizationAPIKey` instances by organization name while keeping the original search behavior, you can write: 276 | 277 | ```python 278 | list_display = [*APIKeyModelAdmin.list_display, "organization__name"] 279 | search_fields = [*APIKeyModelAdmin.search_fields, "organization__name"] 280 | ``` 281 | 282 | !!! question "How can I display API keys on the detail page of a related model instance?" 283 | In theory, this could be done using Django's [`InlineModelAdmin`](https://docs.djangoproject.com/en/2.2/ref/contrib/admin/#inlinemodeladmin-objects). 284 | 285 | However, due to the limitations of inlines, this cannot be easily achieved while correctly saving and displaying the generated key in the detail page of the related model. 286 | 287 | As an alternative, you can use the `.list_filter` class attribute to filter API keys by an identifying field on the related model. In the examples above, you could use `organization__name` to filter API keys by organization. 288 | 289 | ### Permission classes 290 | 291 | The built-in `HasAPIKey` permission class only checks against the built-in `APIKey` model. This means that if you use a custom API key model, you need to create a **custom permission class** for your application to validate API keys against it. 292 | 293 | You can do so by subclassing `BaseHasAPIKey` and specifying the `.model` class attribute: 294 | 295 | ```python 296 | # organizations/permissions.py 297 | from rest_framework_api_key.permissions import BaseHasAPIKey 298 | from .models import OrganizationAPIKey 299 | 300 | class HasOrganizationAPIKey(BaseHasAPIKey): 301 | model = OrganizationAPIKey 302 | ``` 303 | 304 | You can then use `HasOrganizationAPIKey` as described in [Setting permissions](#setting-permissions). 305 | 306 | !!! tip 307 | If you need to customize `.has_permission()` or `.has_object_permission()`, feel free to read the [source code](https://github.com/florimondmanca/djangorestframework-api-key/blob/master/src/rest_framework_api_key/permissions.py). 308 | 309 | #### API key parsing 310 | 311 | By default, API key permission classes retrieve the API key from the `Authorization` header or a custom header, as described in [Making authorized requests](#making-authorized-requests). 312 | 313 | You can customize or override this behavior in several ways. 314 | 315 | If you are building an API for an application you do not control that requires a specific header keyword, e.g. a client that sends API keys using the `Bearer` keyword as follows: 316 | 317 | ``` 318 | Authorization: Bearer 319 | ``` 320 | 321 | Then you can subclass `KeyParser` with a custom `keyword`, and attach it to a custom permission class, like so: 322 | 323 | ```python 324 | # settings.py 325 | from rest_framework_api_key.models import HasAPIKey 326 | from rest_framework_api_key.permissions import BaseHasAPIKey, KeyParser 327 | 328 | class BearerKeyParser(KeyParser): 329 | keyword = "Bearer" 330 | 331 | class HasAPIKey(BaseHasAPIKey): 332 | model = APIKey # Or a custom model 333 | key_parser = BearerKeyParser() 334 | ``` 335 | 336 | You can also override the default header-based parsing completely. 337 | 338 | To do so, redefine the `.get_key()` method on your custom permission class. This method accepts the [HttpRequest](https://docs.djangoproject.com/en/2.2/ref/request-response/#httprequest-objects) object as unique argument and should return the API key as an `str` if one was found, or `None` otherwise. 339 | 340 | For example, here's how you could retrieve the API key from a cookie: 341 | 342 | ```python 343 | class HasAPIKey(BaseHasAPIKey): 344 | model = APIKey # Or a custom model 345 | 346 | def get_key(self, request): 347 | return request.COOKIES.get("api_key") 348 | ``` 349 | 350 | If your custom key parsing algorithm is more complex, you may want to define it as a separate component. To do so, build a key parser class, which must implement the `.get()` method with the same signature as `.get_key()`, then set it as the `.key_parser`, as follows: 351 | 352 | ```python 353 | class CookieKeyParser: 354 | def get(self, request): 355 | cookie_name = getattr(settings, "API_KEY_COOKIE_NAME", "api_key") 356 | return request.COOKIES.get(cookie_name) 357 | 358 | class HasAPIKey(BaseHasAPIKey): 359 | model = APIKey # Or a custom model 360 | key_parser = CookieKeyParser() 361 | ``` 362 | 363 | ### Key generation 364 | 365 | !!! warning 366 | **This is an advanced topic**. Customizing the key generation algorithm must be done with care to prevent security issues. 367 | 368 | If you proceed, it is best to customize key generation **with a clean database state**, that is **before running initial migrations**, and more importantly **before any API key is created**. 369 | 370 | This package ships with a key generation algorithm based on Django's password hashing infrastructure (see also [Security](security.md)). 371 | 372 | The `.key_generator` attribute on `BaseAPIKeyManager` allows you to customize key generation. 373 | 374 | For example, you can customize the length of the prefix and secret key using: 375 | 376 | ```python 377 | from rest_framework_api_key.models import BaseAPIKeyManager 378 | from rest_framework_api_key.crypto import KeyGenerator 379 | 380 | class OrganizationAPIKeyManager(BaseAPIKeyManager): 381 | key_generator = KeyGenerator(prefix_length=8, secret_key_length=32) # Default values 382 | 383 | class OrganizationAPIKey(AbstractAPIKey): 384 | objects = OrganizationAPIKeyManager() 385 | # ... 386 | ``` 387 | 388 | If you want to replace the key generation algorithm entirely, you can create your own `KeyGenerator` class. It must implement the `.generate()` and `.verify()` methods. At this point, it's probably best to read the [source code](https://github.com/florimondmanca/djangorestframework-api-key/blob/master/src/rest_framework_api_key/crypto.py) for the built-in `KeyGenerator`. 389 | 390 | !!! check 391 | If the signature of your `.generate()` method is different from the built-in one, you'll need to override `.assign_key()` in your custom API key manager as well. 392 | 393 | Likewise, if `.verify()` must accept anything else than the `key` and `hashed_key`, you'll need to override `.is_valid()` on your custom API key model. 394 | 395 | See [models.py](https://github.com/florimondmanca/djangorestframework-api-key/blob/master/src/rest_framework_api_key/models.py) for the source code of `BaseAPIKeyManager`. 396 | 397 | ## Typing support 398 | 399 | This package provides type information starting with version 2.0, making it suitable for usage with type checkers such as `mypy`. 400 | 401 | For the best experience, you may want to install packages such as [`django-stubs`](https://github.com/typeddjango/django-stubs) and [`djangorestframework-stubs`](https://github.com/typeddjango/djangorestframework-stubs). Note however that a seamless integration with these packages is not guaranteed yet. 402 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |
2 |

Django REST Framework API Key

3 |

4 | API key permissions for 5 | 6 | Django REST Framework 7 | 8 |

9 | 20 |
21 | python versions 22 | django versions 23 | drf versions 24 |
25 |
26 | 27 | ## Introduction 28 | 29 | **Django REST Framework API Key is a powerful library for allowing server-side clients to safely use your API.** These clients are typically third-party backends and services (i.e. _machines_) which do not have a user account but still need to interact with your API in a secure way. 30 | 31 | ### Features 32 | 33 | - ✌️ **Simple to use**: create, view and revoke API keys via the admin site, or use built-in helpers to create API keys programmatically. 34 | - 🔒 **As secure as possible**: API keys are treated with the same level of care as user passwords. They are only visible at creation and hashed before storing in the database. 35 | - 🎨 **Customizable**: satisfy specific business requirements by building your own customized API key models, permission classes and admin panels. 36 | 37 | ### Should I use API keys? 38 | 39 | There are important security aspects you need to consider before switching to an API key access control scheme. We've listed some of these in [Security caveats](security.md#caveats), including serving your API over HTTPS. 40 | 41 | Besides, see [Why and when to use API keys](https://cloud.google.com/endpoints/docs/openapi/when-why-api-key#top_of_page) for hints on whether API keys can fit your use case. 42 | 43 | API keys are ideal in the following situations: 44 | 45 | - Blocking anonymous traffic. 46 | - Implementing API key-based [throttling](https://www.django-rest-framework.org/api-guide/throttling/). (Note that Django REST Framework already has many built-in utilities for this use case.) 47 | - Identifying usage patterns by logging request information along with the API key. 48 | 49 | They can also present enough security for authorizing internal services, such as your API server and an internal frontend application. 50 | 51 | !!! warning 52 | Please note that this package is NOT meant for authentication. You should NOT use this package to identify individual users, either directly or indirectly. 53 | 54 | If you need server-to-server authentication, you may want to consider OAuth instead. Libraries such as [django-oauth-toolkit](https://django-oauth-toolkit.readthedocs.io/en/latest/index.html) can help. 55 | 56 | ## Quickstart 57 | 58 | Install with `pip`: 59 | 60 | ```bash 61 | pip install "djangorestframework-api-key==3.*" 62 | ``` 63 | 64 | !!! important 65 | It is highly recommended to **pin your dependency** to the latest major version (as depicted above), as breaking changes may and will happen between major releases. 66 | 67 | Add the app to your `INSTALLED_APPS`: 68 | 69 | ```python 70 | # settings.py 71 | 72 | INSTALLED_APPS = [ 73 | # ... 74 | "rest_framework", 75 | "rest_framework_api_key", 76 | ] 77 | ``` 78 | 79 | Run the included migrations: 80 | 81 | ```bash 82 | python manage.py migrate 83 | ``` 84 | 85 | To learn how to configure permissions and manage API keys, head to the [User Guide](guide.md). 86 | -------------------------------------------------------------------------------- /docs/security.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | ## Implementation details 4 | 5 | ### Key generation scheme 6 | 7 | An API key is composed of two items: 8 | 9 | - A prefix `P`, which is a generated string of 8 characters. 10 | - A secret key `SK`, which is a generated string of 32 characters. 11 | 12 | The generated key that clients use to [make authorized requests](#making-authorized-requests) is `GK = P.SK`. It is treated with the same level of care as passwords: 13 | 14 | - Only a hashed version is stored in the database. The hashing algorithm is sha512. [^1] 15 | - The generated key is shown only once to the client upon API key creation. 16 | 17 | [^1]: Older versions of this module used the same hashers as Django's [`PASSWORD_HASHERS`](https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-PASSWORD_HASHERS). These hashers come with a large performance penalty and while critical for passwords, they aren't needed for high-entropy, randomly generated keys like the ones created by this module. Keys stored using these slower hashers will be upgraded when used. 18 | 19 | ### Grant scheme 20 | 21 | Access is granted if and only if all of the following is true: 22 | 23 | 1. The configured API key header is present and correctly formatted. [^3] 24 | 2. A usable API key with the prefix of the given key exists in the database. [^4] 25 | 3. The hash of the given key matches that of the API key. 26 | 27 | [^3]: To customize this behavior, see [API key parsing](guide.md#api-key-parsing). 28 | [^4]: Only unrevoked keys are usable by default, but this can be customized with a [custom manager](guide.md#managers). 29 | 30 | ## Caveats 31 | 32 | [API keys ≠ Security](https://nordicapis.com/why-api-keys-are-not-enough/): depending on your situation, you should probably not use API keys only to authorize your clients. 33 | 34 | Besides, it is NOT recommended to use this package for authentication, i.e. retrieving user information from API keys. 35 | 36 | Indeed, **using API keys shifts the responsibility of Information Security on your clients**. This induces risks, especially if obtaining an API key gives access to confidential information or write operations. For example, an attacker could impersonate clients if they let their API keys leak. 37 | 38 | As a best practice, you should apply the _Principle of Least Privilege_: allow only those who require resources to access those specific resources. In other words: **if your client needs to access an endpoint, add API permissions on that endpoint only** instead of the whole API. 39 | 40 | Besides, it is highly recommended to serve the API over **HTTPS** to ensure the confidentiality of API keys passed in requests. 41 | 42 | Act responsibly! 43 | -------------------------------------------------------------------------------- /docs/upgrade/1.0.md: -------------------------------------------------------------------------------- 1 | # Upgrading to 1.0 2 | 3 | ## Introduction 4 | 5 | **The 1.0 release is incompatible with 0.x**. It introduces a new and more transparent API key generation and validation scheme which allows to pass it in a single header, instead of two previously. 6 | 7 | This document lists the steps necessary to upgrade from 0.x to 1.0. 8 | 9 | ## Steps 10 | 11 | ### 1. Backup existing API keys 12 | 13 | Unfortunately, it is not possible to provide a migration that would preserve existing API keys. This is because the cryptographic generation and validation methods have changed fundamentally, which means that new keys cannot be inferred from existing ones. 14 | 15 | As a result, **all existing API keys will be destroyed** during Step 1/ described below. You may want to backup the existing data so that you can regenerate keys notify clients once migration is complete. 16 | 17 | ### 2. Reset migrations 18 | 19 | **This step will destroy existing API keys**. It is necessary for the new migrations to run correctly. 20 | 21 | ```bash 22 | python manage.py migrate rest_framework_api_key zero 23 | ``` 24 | 25 | ### 3. Upgrade 26 | 27 | ```bash 28 | pip install "djangorestframework-api-key==1.0.*" 29 | ``` 30 | 31 | ### 4. Run migrations 32 | 33 | ```bash 34 | python manage.py migrate rest_framework_api_key 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/upgrade/1.4.md: -------------------------------------------------------------------------------- 1 | # Upgrading to 1.4 2 | 3 | ## Introduction 4 | 5 | The 1.4 release includes a migration that adds and populates the `prefix` and `hashed_key` fields to API keys. 6 | 7 | This document lists the steps necessary to upgrade from 1.3.x to 1.4. 8 | 9 | ## Steps 10 | 11 | ### 1. Migrate the built-in API key model 12 | 13 | The `APIKey` model can be migrated using the migration shipped with this package: 14 | 15 | ```bash 16 | python manage.py migrate rest_framework_api_key 17 | ``` 18 | 19 | ### 2. Migrate custom API key models (if applicable) 20 | 21 | If you have a custom API key model deriving from `AbstractAPIKey`, you need to **manually add the migration** to your application. 22 | 23 | - Copy the migration script below to your app's `migrations/` directory. Be sure to modify `APP_NAME`, `MODEL_NAME` and `DEPENDENCIES` as seems fit. You can name the migration script `xxxx_prefix_hashed_key.py` (replace `xxxx` with the next available migration ID). 24 | 25 | ```python 26 | --8<-- "src/rest_framework_api_key/migrations/0004_prefix_hashed_key.py" 27 | ``` 28 | 29 | - Apply the migration: 30 | 31 | ```bash 32 | python manage.py migrate 33 | ``` 34 | -------------------------------------------------------------------------------- /docs/upgrade/2.0.md: -------------------------------------------------------------------------------- 1 | # Upgrading to 2.0 2 | 3 | ## Introduction 4 | 5 | This document lists the steps necessary to upgrade from 1.4.x to 2.0. 6 | 7 | The full list of changes can be found in the [Changelog](https://github.com/florimondmanca/djangorestframework-api-key/blob/master/CHANGELOG.md). 8 | 9 | ## Steps 10 | 11 | ### 1. Upgrade your Python to 3.6+ 12 | 13 | The 2.0 release dropped support for Python 3.5. 14 | 15 | Before upgrading, make sure you are running on Python 3.6 or above. 16 | 17 | ### 2. Upgrade your Django to 2.2+ 18 | 19 | To 2.0 release dropped official support for Django 2.0 and Django 2.1, which have both reached EOL (see [Django: Supported Versions](https://www.djangoproject.com/download/#supported-versions)). Django 2.2 is still supported, and support for Django 3.0 was added. 20 | 21 | While we there are no internal changes due to this change, you should make sure you are running on Django 2.2 or above before upgrading. 22 | -------------------------------------------------------------------------------- /docs/upgrade/3.0.md: -------------------------------------------------------------------------------- 1 | # Upgrading to 3.0 2 | 3 | ## Introduction 4 | 5 | This document lists the steps necessary to upgrade from 2.x to 3.0. 6 | 7 | The full list of changes can be found in the [Changelog](https://github.com/florimondmanca/djangorestframework-api-key/blob/master/CHANGELOG.md). 8 | 9 | ## Steps 10 | 11 | ### 1. Upgrade your Python to 3.8+ 12 | 13 | The 3.0 release drops support for Python 3.7, which has reached EOL in June 2023. 14 | 15 | Before upgrading, make sure you are running on Python 3.8 or above. 16 | 17 | ### 2. Review usage of `.has_object_permission()` on DRF 3.14+ with custom API key models 18 | 19 | An implementation of `.has_object_permission()` on `BaseHasAPIKey` that was redundant with `.has_permission()` has been dropped when using Django REST Framework 3.14.0 and above. 20 | 21 | If you are using DRF 3.14+ and have custom API key models, you will want to review any implementation of `.has_object_permission()`. Calls to `super().has_object_permission()` will now return `True` (the DRF default) instead of re-validating the API key. This is the desirable thing to do, but you may need some adjustements if you relied on the previous behavior somehow until now. 22 | 23 | ### 3. Review upgrade of API key hashes following switch to SHA512 24 | 25 | This release brings a notable performance improvement by changing the hashing algorithm for API keys. 26 | 27 | Hashing API keys used to be done using Django's `PASSWORD_HASHERS`. These hashers are slow by design as they're meant to deal with low-entropy strings such as user passwords. As a result, they typically added 200ms or more on every single request. On the contrary, API keys are long, randomly generated strings of ASCII characters. This means they have a high entropy, so we can get away with hashing them with a simpler — and faster — algorithm. 28 | 29 | Consequently, version 3.0 now uses SHA512 for hashing API keys. 30 | 31 | Limited testing has shown API key verification should be at least 10x faster on typical CPUs — although greater improvements could be observed. 32 | 33 | The hashes of existing API keys will be transparently updated the next time `.is_valid()` is called (i.e. the next time the API key is used). 34 | 35 | There shouldn't be any action required on your side, but you may want to test things in a staging environment out of caution. 36 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Django REST Framework API Key 2 | site_url: https://florimondmanca.github.io/djangorestframework-api-key 3 | copyright: © 2018-2020 Florimond Manca 4 | theme: 5 | name: "material" 6 | palette: 7 | primary: "red" 8 | accent: "red" 9 | 10 | repo_name: florimondmanca/djangorestframework-api-key 11 | repo_url: https://github.com/florimondmanca/djangorestframework-api-key 12 | 13 | nav: 14 | - Home: index.md 15 | - User Guide: guide.md 16 | - Security: security.md 17 | - Upgrade Guides: 18 | - "3.0": upgrade/3.0.md 19 | - "2.0": upgrade/2.0.md 20 | - "1.4": upgrade/1.4.md 21 | - "1.0": upgrade/1.0.md 22 | - Contributing: https://github.com/florimondmanca/djangorestframework-api-key/tree/master/CONTRIBUTING.md 23 | - Changelog: https://github.com/florimondmanca/djangorestframework-api-key/tree/master/CHANGELOG.md 24 | - License: https://github.com/florimondmanca/djangorestframework-api-key/tree/master/LICENSE 25 | 26 | markdown_extensions: 27 | - admonition 28 | - footnotes 29 | - pymdownx.superfences 30 | - pymdownx.snippets: 31 | check_paths: true 32 | - codehilite: 33 | guess_lang: false 34 | - toc: 35 | permalink: true 36 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "setuptools-scm", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "djangorestframework-api-key" 7 | description = "API key permissions for the Django REST Framework" 8 | requires-python = ">=3.8" 9 | license = { text = "MIT" } 10 | authors = [ 11 | { name = "Florimond Manca", email = "florimond.manca@protonmail.com" }, 12 | ] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "Operating System :: OS Independent", 16 | "Intended Audience :: Developers", 17 | "Programming Language :: Python :: 3 :: Only", 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Environment :: Web Environment", 25 | "Topic :: Software Development :: Libraries :: Python Modules", 26 | "Framework :: Django", 27 | "Framework :: Django :: 2.2", 28 | "Framework :: Django :: 3.2", 29 | "Framework :: Django :: 4.2", 30 | "Framework :: Django :: 5.2", 31 | ] 32 | dependencies = [ 33 | "packaging", 34 | ] 35 | dynamic = ["version", "readme"] 36 | 37 | [project.urls] 38 | "Homepage" = "https://github.com/florimondmanca/djangorestframework-api-key" 39 | "Documentation" = "https://florimondmanca.github.io/djangorestframework-api-key/" 40 | 41 | [tool.setuptools.dynamic] 42 | version = { attr = "rest_framework_api_key.__version__" } 43 | readme = { file = ["README.md", "CHANGELOG.md"], content-type = "text/markdown" } 44 | 45 | [tool.ruff] 46 | select = ["E", "F", "I"] 47 | line-length = 88 48 | src = ["src", "test_project", "tests"] 49 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | 3 | # Django environment. 4 | # django[argon2,bcrypt] # See tools/install_django.sh 5 | djangorestframework==3.14.* 6 | dj-database-url 7 | django-dotenv 8 | 9 | # PostgreSQL testing. 10 | psycopg2-binary 11 | 12 | # Packaging. 13 | twine 14 | wheel 15 | 16 | # Tooling. 17 | black==23.9.1 18 | django-test-migrations==1.3.0 19 | mkdocs==1.5.2 20 | mkdocs-material==9.3.1 21 | pymdown-extensions==10.3 22 | mypy==1.5.1 23 | pytest==7.4.2 24 | pytest-django==4.5.2 25 | pytest-cov 26 | ruff==0.0.289 27 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [mypy] 2 | disallow_untyped_defs = True 3 | ignore_missing_imports = True 4 | 5 | [tool:pytest] 6 | testpaths = tests 7 | addopts = 8 | -rxXs 9 | --cov=src 10 | --cov-report=term-missing 11 | --cov-fail-under=100 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() # Editable installs. 4 | -------------------------------------------------------------------------------- /src/rest_framework_api_key/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | import django 3 | except ImportError: # pragma: no cover 4 | pass 5 | else: 6 | if django.VERSION < (3, 2): # pragma: no cover 7 | default_app_config = "rest_framework_api_key.apps.RestFrameworkApiKeyConfig" 8 | 9 | __version__ = "3.1.0" 10 | 11 | __all__ = ["__version__", "default_app_config"] 12 | -------------------------------------------------------------------------------- /src/rest_framework_api_key/admin.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from django.contrib import admin, messages 4 | from django.db import models 5 | from django.http.request import HttpRequest 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | from .models import AbstractAPIKey, APIKey 9 | 10 | 11 | class APIKeyModelAdmin(admin.ModelAdmin): 12 | model: typing.Type[AbstractAPIKey] 13 | 14 | list_display = ( 15 | "prefix", 16 | "name", 17 | "created", 18 | "expiry_date", 19 | "_has_expired", 20 | "revoked", 21 | ) 22 | list_filter = ("created",) 23 | search_fields = ("name", "prefix") 24 | 25 | def get_readonly_fields( 26 | self, request: HttpRequest, obj: models.Model = None 27 | ) -> typing.Tuple[str, ...]: 28 | obj = typing.cast(AbstractAPIKey, obj) 29 | fields: typing.Tuple[str, ...] 30 | 31 | fields = ("prefix",) 32 | if obj is not None and obj.revoked: 33 | fields = fields + ("name", "revoked", "expiry_date") 34 | 35 | return fields 36 | 37 | def save_model( 38 | self, 39 | request: HttpRequest, 40 | obj: AbstractAPIKey, 41 | form: typing.Any = None, 42 | change: bool = False, 43 | ) -> None: 44 | created = not obj.pk 45 | 46 | if created: 47 | key = self.model.objects.assign_key(obj) 48 | obj.save() 49 | message = _( 50 | "The API key for {} is: {}. ".format(obj.name, key) 51 | + "Please store it somewhere safe: " 52 | + "you will not be able to see it again." 53 | ) 54 | messages.add_message(request, messages.WARNING, message) 55 | else: 56 | obj.save() 57 | 58 | 59 | admin.site.register(APIKey, APIKeyModelAdmin) 60 | 61 | APIKeyAdmin = APIKeyModelAdmin # Compatibility with <1.3 62 | -------------------------------------------------------------------------------- /src/rest_framework_api_key/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class RestFrameworkApiKeyConfig(AppConfig): 5 | name = "rest_framework_api_key" 6 | verbose_name = "API Key Permissions" 7 | -------------------------------------------------------------------------------- /src/rest_framework_api_key/crypto.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import typing 3 | 4 | from django.contrib.auth.hashers import ( 5 | BasePasswordHasher, 6 | check_password, 7 | make_password, 8 | ) 9 | from django.utils.crypto import constant_time_compare, get_random_string 10 | 11 | 12 | def concatenate(left: str, right: str) -> str: 13 | return "{}.{}".format(left, right) 14 | 15 | 16 | def split(concatenated: str) -> typing.Tuple[str, str]: 17 | left, _, right = concatenated.partition(".") 18 | return left, right 19 | 20 | 21 | class Sha512ApiKeyHasher(BasePasswordHasher): 22 | """ 23 | An API key hasher using the sha512 algorithm. 24 | 25 | This hasher should *NEVER* be used in Django's `PASSWORD_HASHERS` setting. 26 | It is insecure for use in hashing passwords, but is safe for hashing 27 | high entropy, randomly generated API keys. 28 | """ 29 | 30 | algorithm = "sha512" 31 | 32 | def salt(self) -> str: 33 | """No need for a salt on a high entropy key.""" 34 | return "" 35 | 36 | def encode(self, password: str, salt: str) -> str: 37 | if salt != "": 38 | raise ValueError("salt is unnecessary for high entropy API tokens.") 39 | hash = hashlib.sha512(password.encode()).hexdigest() 40 | return "%s$$%s" % (self.algorithm, hash) 41 | 42 | def verify(self, password: str, encoded: str) -> bool: 43 | encoded_2 = self.encode(password, "") 44 | return constant_time_compare(encoded, encoded_2) 45 | 46 | 47 | class KeyGenerator: 48 | preferred_hasher = Sha512ApiKeyHasher() 49 | 50 | def __init__(self, prefix_length: int = 8, secret_key_length: int = 32): 51 | self.prefix_length = prefix_length 52 | self.secret_key_length = secret_key_length 53 | 54 | def get_prefix(self) -> str: 55 | return get_random_string(self.prefix_length) 56 | 57 | def get_secret_key(self) -> str: 58 | return get_random_string(self.secret_key_length) 59 | 60 | def hash(self, value: str) -> str: 61 | return make_password(value, hasher=self.preferred_hasher) 62 | 63 | def generate(self) -> typing.Tuple[str, str, str]: 64 | prefix = self.get_prefix() 65 | secret_key = self.get_secret_key() 66 | key = concatenate(prefix, secret_key) 67 | hashed_key = self.hash(key) 68 | return key, prefix, hashed_key 69 | 70 | def verify(self, key: str, hashed_key: str) -> bool: 71 | if self.using_preferred_hasher(hashed_key): 72 | # New simpler hasher 73 | result = self.preferred_hasher.verify(key, hashed_key) 74 | else: 75 | # Slower password hashers from Django 76 | # If verified, these will be transparently updated to the preferred hasher 77 | result = check_password(key, hashed_key) 78 | 79 | return result 80 | 81 | def using_preferred_hasher(self, hashed_key: str) -> bool: 82 | return hashed_key.startswith(f"{self.preferred_hasher.algorithm}$$") 83 | -------------------------------------------------------------------------------- /src/rest_framework_api_key/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-04-03 16:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] # type: ignore 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="APIKey", 14 | fields=[ 15 | ( 16 | "id", 17 | models.CharField( 18 | max_length=100, primary_key=True, serialize=False, unique=True 19 | ), 20 | ), 21 | ("created", models.DateTimeField(auto_now_add=True, db_index=True)), 22 | ("name", models.CharField(default=None, max_length=50)), 23 | ( 24 | "revoked", 25 | models.BooleanField( 26 | blank=True, 27 | default=False, 28 | help_text=( 29 | "If the API key is revoked, clients cannot use it anymore." 30 | ), 31 | ), 32 | ), 33 | ], 34 | options={ 35 | "verbose_name": "API key", 36 | "verbose_name_plural": "API keys", 37 | "ordering": ("-created",), 38 | }, 39 | ) 40 | ] 41 | -------------------------------------------------------------------------------- /src/rest_framework_api_key/migrations/0002_auto_20190529_2243.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-05-29 22:43 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("rest_framework_api_key", "0001_initial")] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name="apikey", 12 | name="expiry_date", 13 | field=models.DateTimeField( 14 | blank=True, 15 | help_text="Once API key expires, clients cannot use it anymore.", 16 | null=True, 17 | verbose_name="Expires", 18 | ), 19 | ), 20 | migrations.AlterField( 21 | model_name="apikey", 22 | name="name", 23 | field=models.CharField( 24 | default=None, 25 | help_text=( 26 | "A free-form name for the API key. Need not be unique. " 27 | "50 characters max." 28 | ), 29 | max_length=50, 30 | ), 31 | ), 32 | migrations.AlterField( 33 | model_name="apikey", 34 | name="revoked", 35 | field=models.BooleanField( 36 | blank=True, 37 | default=False, 38 | help_text=( 39 | "If the API key is revoked, clients cannot use it anymore. " 40 | "(This cannot be undone.)" 41 | ), 42 | ), 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /src/rest_framework_api_key/migrations/0003_auto_20190623_1952.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-06-23 19:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("rest_framework_api_key", "0002_auto_20190529_2243")] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="apikey", 12 | name="id", 13 | field=models.CharField( 14 | editable=False, 15 | max_length=100, 16 | primary_key=True, 17 | serialize=False, 18 | unique=True, 19 | ), 20 | ) 21 | ] 22 | -------------------------------------------------------------------------------- /src/rest_framework_api_key/migrations/0004_prefix_hashed_key.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-06-29 10:38 2 | 3 | from django.db import migrations, models 4 | 5 | APP_NAME = "rest_framework_api_key" 6 | MODEL_NAME = "apikey" 7 | DEPENDENCIES = [(APP_NAME, "0003_auto_20190623_1952")] 8 | 9 | 10 | def populate_prefix_hashed_key(apps, schema_editor) -> None: # type: ignore 11 | model = apps.get_model(APP_NAME, MODEL_NAME) 12 | 13 | for api_key in model.objects.using( 14 | schema_editor.connection.alias 15 | ).all(): # pragma: nodj22 16 | prefix, _, hashed_key = api_key.id.partition(".") 17 | api_key.prefix = prefix 18 | api_key.hashed_key = hashed_key 19 | api_key.save() 20 | 21 | 22 | class Migration(migrations.Migration): 23 | dependencies = DEPENDENCIES 24 | 25 | operations = [ 26 | migrations.AddField( 27 | model_name=MODEL_NAME, 28 | name="hashed_key", 29 | field=models.CharField(max_length=100, null=True), 30 | ), 31 | migrations.AddField( 32 | model_name=MODEL_NAME, 33 | name="prefix", 34 | field=models.CharField(max_length=8, unique=True, null=True), 35 | ), 36 | migrations.RunPython(populate_prefix_hashed_key, migrations.RunPython.noop), 37 | migrations.AlterField( 38 | model_name=MODEL_NAME, 39 | name="hashed_key", 40 | field=models.CharField(max_length=100, editable=False), 41 | ), 42 | migrations.AlterField( 43 | model_name=MODEL_NAME, 44 | name="prefix", 45 | field=models.CharField(max_length=8, unique=True, editable=False), 46 | ), 47 | ] 48 | -------------------------------------------------------------------------------- /src/rest_framework_api_key/migrations/0005_auto_20220110_1102.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-01-10 11:02 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("rest_framework_api_key", "0004_prefix_hashed_key"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="apikey", 14 | name="hashed_key", 15 | field=models.CharField(editable=False, max_length=150), 16 | ), 17 | migrations.AlterField( 18 | model_name="apikey", 19 | name="id", 20 | field=models.CharField( 21 | editable=False, 22 | max_length=150, 23 | primary_key=True, 24 | serialize=False, 25 | unique=True, 26 | ), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /src/rest_framework_api_key/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/florimondmanca/djangorestframework-api-key/02f68e85d2e51f8a2049cb15ee4d5466d38cf1db/src/rest_framework_api_key/migrations/__init__.py -------------------------------------------------------------------------------- /src/rest_framework_api_key/models.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.db import models 5 | from django.utils import timezone 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | from .crypto import KeyGenerator, concatenate, split 9 | 10 | 11 | class BaseAPIKeyManager(models.Manager): 12 | key_generator = KeyGenerator() 13 | 14 | def assign_key(self, obj: "AbstractAPIKey") -> str: 15 | try: 16 | key, prefix, hashed_key = self.key_generator.generate() 17 | except ValueError: # Compatibility with < 1.4 18 | generate = typing.cast( 19 | typing.Callable[[], typing.Tuple[str, str]], self.key_generator.generate 20 | ) 21 | key, hashed_key = generate() 22 | pk = hashed_key 23 | prefix, hashed_key = split(hashed_key) 24 | else: 25 | pk = concatenate(prefix, hashed_key) 26 | 27 | obj.id = pk 28 | obj.prefix = prefix 29 | obj.hashed_key = hashed_key 30 | 31 | return key 32 | 33 | def create_key(self, **kwargs: typing.Any) -> typing.Tuple["AbstractAPIKey", str]: 34 | # Prevent from manually setting the primary key. 35 | kwargs.pop("id", None) 36 | obj = self.model(**kwargs) 37 | key = self.assign_key(obj) 38 | obj.save() 39 | return obj, key 40 | 41 | def get_usable_keys(self) -> models.QuerySet: 42 | return self.filter(revoked=False) 43 | 44 | def get_from_key(self, key: str) -> "AbstractAPIKey": 45 | prefix, _, _ = key.partition(".") 46 | queryset = self.get_usable_keys() 47 | 48 | try: 49 | api_key = queryset.get(prefix=prefix) 50 | except self.model.DoesNotExist: 51 | raise # For the sake of being explicit. 52 | 53 | if not api_key.is_valid(key): 54 | raise self.model.DoesNotExist("Key is not valid.") 55 | else: 56 | return api_key 57 | 58 | def is_valid(self, key: str) -> bool: 59 | try: 60 | api_key = self.get_from_key(key) 61 | except self.model.DoesNotExist: 62 | return False 63 | 64 | if api_key.has_expired: 65 | return False 66 | 67 | return True 68 | 69 | 70 | class APIKeyManager(BaseAPIKeyManager): 71 | pass 72 | 73 | 74 | class AbstractAPIKey(models.Model): 75 | objects = APIKeyManager() 76 | 77 | id = models.CharField(max_length=150, unique=True, primary_key=True, editable=False) 78 | prefix = models.CharField(max_length=8, unique=True, editable=False) 79 | hashed_key = models.CharField(max_length=150, editable=False) 80 | created = models.DateTimeField(auto_now_add=True, db_index=True) 81 | name = models.CharField( 82 | max_length=50, 83 | blank=False, 84 | default=None, 85 | help_text=( 86 | _( 87 | "A free-form name for the API key. " 88 | "Need not be unique. " 89 | "50 characters max." 90 | ) 91 | ), 92 | ) 93 | revoked = models.BooleanField( 94 | blank=True, 95 | default=False, 96 | help_text=( 97 | _( 98 | "If the API key is revoked, clients cannot use it anymore. " 99 | "(This cannot be undone.)" 100 | ) 101 | ), 102 | ) 103 | expiry_date = models.DateTimeField( 104 | blank=True, 105 | null=True, 106 | verbose_name=_("Expires"), 107 | help_text=_("Once API key expires, clients cannot use it anymore."), 108 | ) 109 | 110 | class Meta: # noqa 111 | abstract = True 112 | ordering = ("-created",) 113 | verbose_name = "API key" 114 | verbose_name_plural = "API keys" 115 | 116 | def __init__(self, *args: typing.Any, **kwargs: typing.Any): 117 | super().__init__(*args, **kwargs) 118 | # Store the initial value of `revoked` to detect changes. 119 | self._initial_revoked = self.revoked 120 | 121 | def _has_expired(self) -> bool: 122 | if self.expiry_date is None: 123 | return False 124 | return self.expiry_date < timezone.now() 125 | 126 | _has_expired.short_description = "Has expired" # type: ignore 127 | _has_expired.boolean = True # type: ignore 128 | has_expired = property(_has_expired) 129 | 130 | def is_valid(self, key: str) -> bool: 131 | key_generator = type(self).objects.key_generator 132 | valid = key_generator.verify(key, self.hashed_key) 133 | 134 | # Transparently update the key to use the preferred hasher 135 | # if it is using an outdated hasher. 136 | if valid and not key_generator.using_preferred_hasher(self.hashed_key): 137 | # Note that since the PK includes the hashed key, 138 | # they will be internally inconsistent following this upgrade. 139 | # See: https://github.com/florimondmanca/djangorestframework-api-key/issues/128 140 | self.hashed_key = key_generator.hash(key) 141 | self.save() 142 | 143 | return valid 144 | 145 | def clean(self) -> None: 146 | self._validate_revoked() 147 | 148 | def save(self, *args: typing.Any, **kwargs: typing.Any) -> None: 149 | self._validate_revoked() 150 | super().save(*args, **kwargs) 151 | 152 | def _validate_revoked(self) -> None: 153 | if self._initial_revoked and not self.revoked: 154 | raise ValidationError( 155 | _("The API key has been revoked, which cannot be undone.") 156 | ) 157 | 158 | def __str__(self) -> str: 159 | return str(self.name) 160 | 161 | 162 | class APIKey(AbstractAPIKey): 163 | pass 164 | -------------------------------------------------------------------------------- /src/rest_framework_api_key/permissions.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import packaging.version 4 | from django.conf import settings 5 | from django.http import HttpRequest 6 | from rest_framework import __version__ as __drf_version__ 7 | from rest_framework import permissions 8 | 9 | from .models import AbstractAPIKey, APIKey 10 | 11 | _drf_version = packaging.version.parse(__drf_version__) 12 | _3_14_0 = packaging.version.parse("3.14.0") 13 | 14 | 15 | class KeyParser: 16 | keyword = "Api-Key" 17 | 18 | def get(self, request: HttpRequest) -> typing.Optional[str]: 19 | custom_header = getattr(settings, "API_KEY_CUSTOM_HEADER", None) 20 | 21 | if custom_header is not None: 22 | return self.get_from_header(request, custom_header) 23 | 24 | return self.get_from_authorization(request) 25 | 26 | def get_from_authorization(self, request: HttpRequest) -> typing.Optional[str]: 27 | authorization = request.META.get("HTTP_AUTHORIZATION", "") 28 | 29 | if not authorization: 30 | return None 31 | 32 | keyword, found, key = authorization.partition(" ") 33 | 34 | if not found: 35 | return None 36 | 37 | if keyword.lower() != self.keyword.lower(): 38 | return None 39 | 40 | return key 41 | 42 | def get_from_header(self, request: HttpRequest, name: str) -> typing.Optional[str]: 43 | return request.META.get(name) or None 44 | 45 | 46 | class BaseHasAPIKey(permissions.BasePermission): 47 | model: typing.Optional[typing.Type[AbstractAPIKey]] = None 48 | key_parser = KeyParser() 49 | 50 | def get_key(self, request: HttpRequest) -> typing.Optional[str]: 51 | return self.key_parser.get(request) 52 | 53 | def has_permission(self, request: HttpRequest, view: typing.Any) -> bool: 54 | assert self.model is not None, ( 55 | "%s must define `.model` with the API key model to use" 56 | % self.__class__.__name__ 57 | ) 58 | key = self.get_key(request) 59 | if not key: 60 | return False 61 | return self.model.objects.is_valid(key) 62 | 63 | def has_object_permission( 64 | self, request: HttpRequest, view: typing.Any, obj: AbstractAPIKey 65 | ) -> bool: 66 | if _drf_version < _3_14_0: # pragma: no cover 67 | # Before DRF 3.14.0 (released in Sept 2022), bitwise OR would skip 68 | # .has_permision() and only call .has_object_permission(), resulting in 69 | # API key permissions not being checked unless we implemented 70 | # .has_object_permission(). 71 | # Since 3.14.0, DRF appropriately checks for both .has_permission() and 72 | # .has_object_permission() when checking object permissions of a bitwise OR. 73 | # We kept the old redundant behavior to avoid regressions for users who have 74 | # not updated their DRF yet. 75 | return self.has_permission(request, view) 76 | 77 | return super().has_object_permission(request, view, obj) 78 | 79 | 80 | class HasAPIKey(BaseHasAPIKey): 81 | model = APIKey 82 | -------------------------------------------------------------------------------- /src/rest_framework_api_key/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/florimondmanca/djangorestframework-api-key/02f68e85d2e51f8a2049cb15ee4d5466d38cf1db/src/rest_framework_api_key/py.typed -------------------------------------------------------------------------------- /test_project/README.md: -------------------------------------------------------------------------------- 1 | # project 2 | 3 | Test project for Django REST Framework API Key. 4 | 5 | ## Usage 6 | 7 | - Run migrations (creates an SQLite database): 8 | 9 | ```bash 10 | python test_project/manage.py migrate 11 | ``` 12 | 13 | - Create a superuser to access the admin site: 14 | 15 | ```bash 16 | python test_project/manage.py createsuperuser 17 | # Enter user information as instructed 18 | ``` 19 | 20 | - Start the server: 21 | 22 | ```bash 23 | python test_project/manage.py runserver 24 | ``` 25 | -------------------------------------------------------------------------------- /test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/florimondmanca/djangorestframework-api-key/02f68e85d2e51f8a2049cb15ee4d5466d38cf1db/test_project/__init__.py -------------------------------------------------------------------------------- /test_project/heroes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/florimondmanca/djangorestframework-api-key/02f68e85d2e51f8a2049cb15ee4d5466d38cf1db/test_project/heroes/__init__.py -------------------------------------------------------------------------------- /test_project/heroes/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from rest_framework_api_key.admin import APIKeyModelAdmin 4 | 5 | from .models import Hero, HeroAPIKey 6 | 7 | 8 | @admin.register(HeroAPIKey) 9 | class HeroAPIKeyModelAdmin(APIKeyModelAdmin): 10 | pass 11 | 12 | 13 | @admin.register(Hero) 14 | class HeroModelAdmin(admin.ModelAdmin): 15 | pass 16 | -------------------------------------------------------------------------------- /test_project/heroes/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class HeroesConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "test_project.heroes" 7 | -------------------------------------------------------------------------------- /test_project/heroes/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-06-23 19:53 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | operations = [ 11 | migrations.CreateModel( 12 | name="Hero", 13 | fields=[ 14 | ( 15 | "id", 16 | models.AutoField( 17 | auto_created=True, 18 | primary_key=True, 19 | serialize=False, 20 | verbose_name="ID", 21 | ), 22 | ), 23 | ("name", models.CharField(max_length=64)), 24 | ("retired", models.BooleanField(default=False)), 25 | ], 26 | options={"verbose_name_plural": "heroes"}, 27 | ), 28 | migrations.CreateModel( 29 | name="HeroAPIKey", 30 | fields=[ 31 | ( 32 | "id", 33 | models.CharField( 34 | editable=False, 35 | max_length=100, 36 | primary_key=True, 37 | serialize=False, 38 | unique=True, 39 | ), 40 | ), 41 | ("created", models.DateTimeField(auto_now_add=True, db_index=True)), 42 | ( 43 | "name", 44 | models.CharField( 45 | default=None, 46 | help_text=( 47 | "A free-form name for the API key. Need not be unique. " 48 | "50 characters max." 49 | ), 50 | max_length=50, 51 | ), 52 | ), 53 | ( 54 | "revoked", 55 | models.BooleanField( 56 | blank=True, 57 | default=False, 58 | help_text=( 59 | "If the API key is revoked, clients cannot use it anymore. " 60 | "(This cannot be undone.)" 61 | ), 62 | ), 63 | ), 64 | ( 65 | "expiry_date", 66 | models.DateTimeField( 67 | blank=True, 68 | help_text=( 69 | "Once API key expires, clients cannot use it anymore." 70 | ), 71 | null=True, 72 | verbose_name="Expires", 73 | ), 74 | ), 75 | ( 76 | "hero", 77 | models.ForeignKey( 78 | on_delete=django.db.models.deletion.CASCADE, 79 | related_name="api_keys", 80 | to="heroes.Hero", 81 | ), 82 | ), 83 | ], 84 | options={ 85 | "verbose_name": "Hero API key", 86 | "verbose_name_plural": "Hero API keys", 87 | "ordering": ("-created",), 88 | "abstract": False, 89 | }, 90 | ), 91 | ] 92 | -------------------------------------------------------------------------------- /test_project/heroes/migrations/0002_prefix_hashed_key.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-06-29 10:38 2 | 3 | from django.db import migrations, models 4 | 5 | APP_NAME = "heroes" 6 | MODEL_NAME = "heroapikey" 7 | DEPENDENCIES = [(APP_NAME, "0001_initial")] 8 | 9 | 10 | def populate_prefix_hashed_key(apps, schema_editor): # type: ignore 11 | model = apps.get_model(APP_NAME, MODEL_NAME) 12 | 13 | for api_key in model.objects.using(schema_editor.connection.alias).all(): 14 | prefix, _, hashed_key = api_key.id.partition(".") 15 | api_key.prefix = prefix 16 | api_key.hashed_key = hashed_key 17 | api_key.save() 18 | 19 | 20 | class Migration(migrations.Migration): 21 | dependencies = DEPENDENCIES 22 | 23 | operations = [ 24 | migrations.AddField( 25 | model_name=MODEL_NAME, 26 | name="hashed_key", 27 | field=models.CharField(max_length=100, null=True), 28 | ), 29 | migrations.AddField( 30 | model_name=MODEL_NAME, 31 | name="prefix", 32 | field=models.CharField(max_length=8, unique=True, null=True), 33 | ), 34 | migrations.RunPython(populate_prefix_hashed_key, migrations.RunPython.noop), 35 | migrations.AlterField( 36 | model_name=MODEL_NAME, 37 | name="hashed_key", 38 | field=models.CharField(max_length=100, editable=False), 39 | ), 40 | migrations.AlterField( 41 | model_name=MODEL_NAME, 42 | name="prefix", 43 | field=models.CharField(max_length=8, unique=True, editable=False), 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /test_project/heroes/migrations/0003_alter_hero_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-05 17:09 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("heroes", "0002_prefix_hashed_key"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="hero", 14 | name="id", 15 | field=models.BigAutoField( 16 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /test_project/heroes/migrations/0004_auto_20220110_1102.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-01-10 11:02 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("heroes", "0003_alter_hero_id"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="heroapikey", 14 | name="hashed_key", 15 | field=models.CharField(editable=False, max_length=150), 16 | ), 17 | migrations.AlterField( 18 | model_name="heroapikey", 19 | name="id", 20 | field=models.CharField( 21 | editable=False, 22 | max_length=150, 23 | primary_key=True, 24 | serialize=False, 25 | unique=True, 26 | ), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /test_project/heroes/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/florimondmanca/djangorestframework-api-key/02f68e85d2e51f8a2049cb15ee4d5466d38cf1db/test_project/heroes/migrations/__init__.py -------------------------------------------------------------------------------- /test_project/heroes/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from rest_framework_api_key.models import AbstractAPIKey, BaseAPIKeyManager 4 | 5 | 6 | class Hero(models.Model): 7 | objects = models.Manager() 8 | name = models.CharField(max_length=64) 9 | retired = models.BooleanField(default=False) 10 | 11 | class Meta: 12 | verbose_name_plural = "heroes" 13 | 14 | def __str__(self) -> str: 15 | return self.name 16 | 17 | 18 | class HeroAPIKeyManager(BaseAPIKeyManager): 19 | def get_usable_keys(self) -> models.QuerySet: 20 | return super().get_usable_keys().filter(hero__retired=False) 21 | 22 | 23 | class HeroAPIKey(AbstractAPIKey): 24 | objects = HeroAPIKeyManager() 25 | hero = models.ForeignKey(Hero, on_delete=models.CASCADE, related_name="api_keys") 26 | 27 | class Meta(AbstractAPIKey.Meta): 28 | verbose_name = "Hero API key" 29 | verbose_name_plural = "Hero API keys" 30 | -------------------------------------------------------------------------------- /test_project/heroes/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework_api_key.permissions import BaseHasAPIKey 2 | 3 | from .models import HeroAPIKey 4 | 5 | 6 | class HasHeroAPIKey(BaseHasAPIKey): 7 | model = HeroAPIKey 8 | -------------------------------------------------------------------------------- /test_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import pathlib 4 | import sys 5 | 6 | import dotenv 7 | 8 | root = pathlib.Path(__file__).parent.parent 9 | 10 | if __name__ == "__main__": 11 | dotenv.read_dotenv(str(root / ".env")) 12 | 13 | sys.path.append(str(root)) 14 | 15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.project.settings") 16 | try: 17 | from django.core.management import execute_from_command_line 18 | except ImportError as exc: 19 | raise ImportError( 20 | "Couldn't import Django. Are you sure it's installed and " 21 | "available on your PYTHONPATH environment variable? Did you " 22 | "forget to activate a virtual environment?" 23 | ) from exc 24 | execute_from_command_line(sys.argv) 25 | -------------------------------------------------------------------------------- /test_project/project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/florimondmanca/djangorestframework-api-key/02f68e85d2e51f8a2049cb15ee4d5466d38cf1db/test_project/project/__init__.py -------------------------------------------------------------------------------- /test_project/project/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import dj_database_url 4 | 5 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 6 | SECRET_KEY = "*(q7fo7duyzqhv$qmiym5#z%w^qqh67a9rqk$jr4n1))nvnql^" 7 | DEBUG = True 8 | 9 | ALLOWED_HOSTS = ["localhost", "127.0.0.1"] 10 | 11 | 12 | # Application definition 13 | 14 | INSTALLED_APPS = [ 15 | "django.contrib.admin", 16 | "django.contrib.auth", 17 | "django.contrib.contenttypes", 18 | "django.contrib.sessions", 19 | "django.contrib.messages", 20 | "django.contrib.staticfiles", 21 | "rest_framework", 22 | "rest_framework_api_key", 23 | "test_project.heroes", 24 | ] 25 | 26 | MIDDLEWARE = [ 27 | "django.middleware.security.SecurityMiddleware", 28 | "django.contrib.sessions.middleware.SessionMiddleware", 29 | "django.middleware.common.CommonMiddleware", 30 | "django.middleware.csrf.CsrfViewMiddleware", 31 | "django.contrib.auth.middleware.AuthenticationMiddleware", 32 | "django.contrib.messages.middleware.MessageMiddleware", 33 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 34 | ] 35 | 36 | ROOT_URLCONF = "project.urls" 37 | 38 | TEMPLATES = [ 39 | { 40 | "BACKEND": "django.template.backends.django.DjangoTemplates", 41 | "DIRS": [], 42 | "APP_DIRS": True, 43 | "OPTIONS": { 44 | "context_processors": [ 45 | "django.template.context_processors.debug", 46 | "django.template.context_processors.request", 47 | "django.contrib.auth.context_processors.auth", 48 | "django.contrib.messages.context_processors.messages", 49 | ] 50 | }, 51 | } 52 | ] 53 | 54 | WSGI_APPLICATION = "project.wsgi.application" 55 | 56 | # Database 57 | 58 | DATABASES = { 59 | "default": dj_database_url.config(default="sqlite:///db.sqlite3"), 60 | "test": dj_database_url.config( 61 | "TEST_DATABASE_URL", default="sqlite://test.sqlite3" 62 | ), 63 | } 64 | 65 | 66 | # Password validation 67 | 68 | AUTH_PASSWORD_VALIDATORS = [ 69 | { 70 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" # noqa: E501 71 | }, 72 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 73 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 74 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 75 | ] 76 | 77 | # Password hashers 78 | 79 | PASSWORD_HASHERS = [ 80 | "django.contrib.auth.hashers.PBKDF2PasswordHasher", 81 | "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", 82 | "django.contrib.auth.hashers.Argon2PasswordHasher", 83 | "django.contrib.auth.hashers.BCryptSHA256PasswordHasher", 84 | "django.contrib.auth.hashers.ScryptPasswordHasher", 85 | ] 86 | 87 | 88 | # Internationalization 89 | 90 | LANGUAGE_CODE = "en-us" 91 | TIME_ZONE = "UTC" 92 | USE_I18N = True 93 | USE_L10N = True 94 | USE_TZ = True 95 | 96 | 97 | # Static files (CSS, JavaScript, Images) 98 | 99 | STATIC_URL = "/static/" 100 | -------------------------------------------------------------------------------- /test_project/project/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | path("admin/", admin.site.urls), 8 | path("api/public/", views.PublicAPIView.as_view()), 9 | path("api/protected/", views.ProtectedAPIView.as_view()), 10 | path("api/protected/object/", views.ProtectedObjectAPIView.as_view()), 11 | ] 12 | -------------------------------------------------------------------------------- /test_project/project/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.request import Request 2 | from rest_framework.response import Response 3 | from rest_framework.views import APIView 4 | 5 | from test_project.heroes.permissions import HasHeroAPIKey 6 | 7 | 8 | class PublicAPIView(APIView): 9 | def get(self, request: Request) -> Response: 10 | return Response({"message": "Hello, world!"}) 11 | 12 | 13 | class ProtectedAPIView(APIView): 14 | permission_classes = [HasHeroAPIKey] 15 | 16 | def get(self, request: Request) -> Response: 17 | return Response({"message": "Hello, world!"}) 18 | 19 | 20 | class ProtectedObjectAPIView(APIView): 21 | permission_classes = [HasHeroAPIKey] 22 | 23 | def get(self, request: Request) -> Response: 24 | self.check_object_permissions(request, object()) 25 | return Response({"message": "Hello, world!"}) 26 | -------------------------------------------------------------------------------- /test_project/project/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 6 | application = get_wsgi_application() 7 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/florimondmanca/djangorestframework-api-key/02f68e85d2e51f8a2049cb15ee4d5466d38cf1db/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import dj_database_url 4 | import dotenv 5 | from django.conf import settings 6 | 7 | 8 | def pytest_configure() -> None: 9 | dotenv.read_dotenv(str(Path(__file__).parent.parent / ".env")) 10 | 11 | settings.configure( 12 | **{ 13 | "SECRET_KEY": "abcd", 14 | "INSTALLED_APPS": [ 15 | # Mandatory 16 | "django.contrib.contenttypes", 17 | # Permissions 18 | "django.contrib.auth", 19 | # Admin 20 | "django.contrib.admin", 21 | "django.contrib.messages", 22 | "django.contrib.sessions", 23 | # Project 24 | "rest_framework", 25 | "rest_framework_api_key", 26 | "test_project.heroes", 27 | ], 28 | "TEMPLATES": [ 29 | # Admin 30 | { 31 | "BACKEND": "django.template.backends.django.DjangoTemplates", 32 | "OPTIONS": { 33 | "context_processors": [ 34 | "django.contrib.auth.context_processors.auth", 35 | "django.contrib.messages.context_processors.messages", 36 | ] 37 | }, 38 | } 39 | ], 40 | "MIDDLEWARE": [ 41 | # Admin 42 | "django.contrib.sessions.middleware.SessionMiddleware", 43 | "django.contrib.messages.middleware.MessageMiddleware", 44 | "django.contrib.auth.middleware.AuthenticationMiddleware", 45 | ], 46 | "ROOT_URLCONF": "test_project.project.urls", 47 | "DATABASES": { 48 | "default": dj_database_url.config(default="sqlite://:memory:"), 49 | "test": dj_database_url.config(default="sqlite://:memory:"), 50 | }, 51 | "USE_TZ": True, 52 | } 53 | ) 54 | -------------------------------------------------------------------------------- /tests/dateutils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | 3 | NOW = datetime.now(tz=timezone.utc) 4 | TOMORROW = NOW + timedelta(days=1) 5 | YESTERDAY = NOW - timedelta(days=1) 6 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.admin import site 3 | from django.contrib.messages import get_messages 4 | from django.contrib.messages.middleware import MessageMiddleware 5 | from django.contrib.sessions.middleware import SessionMiddleware 6 | from django.http.request import HttpRequest 7 | from django.http.response import HttpResponse 8 | from django.test import RequestFactory 9 | from test_project.heroes.admin import HeroAPIKeyModelAdmin 10 | from test_project.heroes.models import Hero, HeroAPIKey 11 | 12 | from rest_framework_api_key.admin import APIKeyModelAdmin 13 | from rest_framework_api_key.models import APIKey 14 | 15 | 16 | def build_admin_request(rf: RequestFactory) -> HttpRequest: 17 | request = rf.post("/") 18 | 19 | def get_response(request: HttpRequest) -> HttpResponse: 20 | raise NotImplementedError # pragma: no cover # Unused in these tests. 21 | 22 | # NOTE: all middleware must be instantiated before 23 | # any middleware can process the request. 24 | sessions = SessionMiddleware(get_response) 25 | messages = MessageMiddleware(sessions.get_response) 26 | 27 | sessions.process_request(request) 28 | messages.process_request(request) 29 | 30 | return request 31 | 32 | 33 | @pytest.mark.django_db 34 | def test_admin_readonly_fields(rf: RequestFactory) -> None: 35 | request = build_admin_request(rf) 36 | 37 | admin = APIKeyModelAdmin(APIKey, site) 38 | 39 | assert admin.get_readonly_fields(request) == ("prefix",) 40 | 41 | api_key = APIKey(name="test") 42 | assert admin.get_readonly_fields(request, obj=api_key) == ("prefix",) 43 | 44 | api_key = APIKey(name="test", revoked=True) 45 | assert admin.get_readonly_fields(request, obj=api_key) == ( 46 | "prefix", 47 | "name", 48 | "revoked", 49 | "expiry_date", 50 | ) 51 | 52 | 53 | @pytest.mark.django_db 54 | def test_admin_create_api_key(rf: RequestFactory) -> None: 55 | request = build_admin_request(rf) 56 | 57 | admin = APIKeyModelAdmin(APIKey, site) 58 | api_key = APIKey(name="test") 59 | 60 | assert not api_key.pk 61 | admin.save_model(request, obj=api_key) 62 | assert api_key.pk 63 | 64 | messages = get_messages(request) 65 | assert len(messages) == 1 66 | 67 | 68 | @pytest.mark.django_db 69 | def test_admin_create_custom_api_key(rf: RequestFactory) -> None: 70 | request = build_admin_request(rf) 71 | 72 | admin = HeroAPIKeyModelAdmin(HeroAPIKey, site) 73 | api_key = HeroAPIKey(name="test", hero=Hero.objects.create()) 74 | 75 | assert not api_key.pk 76 | admin.save_model(request, obj=api_key) 77 | assert api_key.pk 78 | 79 | messages = get_messages(request) 80 | assert len(messages) == 1 81 | 82 | 83 | @pytest.mark.django_db 84 | def test_admin_update_api_key(rf: RequestFactory) -> None: 85 | request = build_admin_request(rf) 86 | 87 | admin = APIKeyModelAdmin(APIKey, site) 88 | api_key, _ = APIKey.objects.create_key(name="test") 89 | 90 | api_key.name = "another-test" 91 | admin.save_model(request, obj=api_key) 92 | refreshed = APIKey.objects.get(pk=api_key.pk) 93 | assert refreshed.name == "another-test" 94 | -------------------------------------------------------------------------------- /tests/test_checks.py: -------------------------------------------------------------------------------- 1 | from django.core.management import call_command 2 | 3 | 4 | def test_system_checks_pass() -> None: 5 | call_command("check") 6 | -------------------------------------------------------------------------------- /tests/test_compatibility.py: -------------------------------------------------------------------------------- 1 | import django 2 | import pytest 3 | 4 | import rest_framework_api_key 5 | 6 | 7 | @pytest.mark.skipif( 8 | django.VERSION < (3, 2), reason="app config is automatically defined by django" 9 | ) 10 | def test_app_config_not_defined() -> None: # pragma: no cover 11 | assert hasattr(rest_framework_api_key, "default_app_config") is False 12 | 13 | 14 | @pytest.mark.skipif( 15 | django.VERSION >= (3, 2), reason="app config is not automatically defined by django" 16 | ) 17 | def test_app_config_defined() -> None: # pragma: no cover 18 | assert hasattr(rest_framework_api_key, "default_app_config") is True 19 | -------------------------------------------------------------------------------- /tests/test_hashers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from rest_framework_api_key.crypto import Sha512ApiKeyHasher 4 | 5 | 6 | def test_sha512hasher_encode() -> None: 7 | hasher = Sha512ApiKeyHasher() 8 | 9 | key = "test" 10 | hashed_key = hasher.encode(key, "") 11 | assert hasher.verify(key, hashed_key) 12 | assert not hasher.verify("not-test", hashed_key) 13 | 14 | 15 | def test_sha512hasher_invalid_salt() -> None: 16 | hasher = Sha512ApiKeyHasher() 17 | with pytest.raises(ValueError): 18 | hasher.encode("test", "salt") 19 | -------------------------------------------------------------------------------- /tests/test_legacy_key_generator.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import pytest 4 | from django.contrib.auth.hashers import make_password 5 | from django.utils.crypto import get_random_string 6 | 7 | from rest_framework_api_key.crypto import KeyGenerator 8 | from rest_framework_api_key.models import APIKey, BaseAPIKeyManager 9 | 10 | pytestmark = pytest.mark.django_db 11 | 12 | 13 | class LegacyKeyGenerator(KeyGenerator): 14 | """ 15 | Pre-1.4 key generator. 16 | 17 | The key generator interface was updated in v1.4 via: 18 | https://github.com/florimondmanca/djangorestframework-api-key/pull/62 19 | 20 | We must ensure that custom key generators created based on the pre-1.4 interface 21 | continue to work in 1.x. 22 | """ 23 | 24 | def generate(self) -> Tuple[str, str]: # type: ignore 25 | # NOTE: this method should replicate the behavior before #62, and 26 | # have no dependencies on the current `rest_framework_api_key` package. 27 | prefix = get_random_string(8) 28 | secret_key = get_random_string(32) 29 | key = prefix + "." + secret_key 30 | key_id = prefix + "." + make_password(key) 31 | return key, key_id 32 | 33 | 34 | def test_manager_with_legacy_key_generator() -> None: 35 | class Manager(BaseAPIKeyManager): 36 | key_generator = LegacyKeyGenerator() 37 | 38 | manager = Manager() 39 | manager.model = APIKey 40 | 41 | api_key, generated_key = manager.create_key(name="test") 42 | assert api_key.is_valid(generated_key) 43 | -------------------------------------------------------------------------------- /tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Tuple 2 | 3 | import pytest 4 | 5 | try: 6 | from django_test_migrations.migrator import Migrator 7 | except ImportError: # pragma: no cover 8 | # Most likely Django < 3.2 9 | Migrator = None # type: ignore 10 | 11 | pytestmark = pytest.mark.skipif( 12 | Migrator is None, 13 | reason="django-test-migrations is not available", 14 | ) 15 | 16 | 17 | @pytest.mark.django_db 18 | def test_migrations_0001_initial(migrator: Migrator) -> None: 19 | old_state = migrator.apply_initial_migration(("rest_framework_api_key", None)) 20 | 21 | with pytest.raises(LookupError): 22 | old_state.apps.get_model("rest_framework_api_key", "APIKey") 23 | 24 | new_state = migrator.apply_tested_migration( 25 | ("rest_framework_api_key", "0001_initial") 26 | ) 27 | APIKey = new_state.apps.get_model("rest_framework_api_key", "APIKey") 28 | assert APIKey.objects.count() == 0 29 | 30 | 31 | @pytest.mark.django_db 32 | def test_migrations_0004_prefix_hashed_key(migrator: Migrator) -> None: 33 | from django.contrib.auth.hashers import make_password 34 | from django.utils.crypto import get_random_string 35 | 36 | def _generate() -> Tuple[str, str]: 37 | # Replicate bejavior before PR #62 (i.e. before v1.4). 38 | prefix = get_random_string(8) 39 | secret_key = get_random_string(32) 40 | key = prefix + "." + secret_key 41 | key_id = prefix + "." + make_password(key) 42 | return key, key_id 43 | 44 | def _assign_key(obj: Any) -> None: 45 | # Replicate bejavior before PR #62 (i.e. before v1.4). 46 | _, hashed_key = _generate() 47 | pk = hashed_key 48 | prefix, _, hashed_key = hashed_key.partition(".") 49 | 50 | obj.id = pk 51 | obj.prefix = prefix 52 | obj.hashed_key = hashed_key 53 | 54 | old_state = migrator.apply_initial_migration( 55 | ("rest_framework_api_key", "0003_auto_20190623_1952") 56 | ) 57 | 58 | APIKey = old_state.apps.get_model("rest_framework_api_key", "APIKey") 59 | 60 | # Create a key as it if were created before PR #62 (i.e. before v1.4). 61 | api_key = APIKey.objects.create(name="test") 62 | _assign_key(api_key) 63 | api_key.save() 64 | prefix, _, hashed_key = api_key.id.partition(".") 65 | 66 | # Apply migration added by PR #62. 67 | new_state = migrator.apply_tested_migration( 68 | ("rest_framework_api_key", "0004_prefix_hashed_key") 69 | ) 70 | APIKey = new_state.apps.get_model("rest_framework_api_key", "APIKey") 71 | 72 | # Ensure new `prefix`` and `hashed_key` fields were successfully populated. 73 | api_key = APIKey.objects.get(id=api_key.id) 74 | assert api_key.prefix == prefix 75 | assert api_key.hashed_key == hashed_key 76 | -------------------------------------------------------------------------------- /tests/test_model.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import string 3 | 4 | import pytest 5 | from django.contrib.auth.hashers import make_password 6 | from django.core.exceptions import ValidationError 7 | from django.db.utils import IntegrityError 8 | from test_project.heroes.models import Hero, HeroAPIKey 9 | 10 | from rest_framework_api_key.models import APIKey 11 | 12 | from .dateutils import NOW, TOMORROW, YESTERDAY 13 | 14 | pytestmark = pytest.mark.django_db 15 | 16 | 17 | def test_key_generation() -> None: 18 | api_key, generated_key = APIKey.objects.create_key(name="test") 19 | prefix = api_key.prefix 20 | hashed_key = api_key.hashed_key 21 | 22 | assert prefix and hashed_key 23 | 24 | charset = set(string.ascii_letters + string.digits + ".") 25 | assert all(c in charset for c in generated_key) 26 | 27 | # The generated key must be valid… 28 | assert api_key.is_valid(generated_key) is True 29 | 30 | # But not the hashed key. 31 | assert api_key.is_valid(hashed_key) is False 32 | 33 | 34 | def test_name_is_required() -> None: 35 | with pytest.raises(IntegrityError): 36 | APIKey.objects.create() 37 | 38 | 39 | def test_cannot_unrevoke() -> None: 40 | api_key, _ = APIKey.objects.create_key(name="test", revoked=True) 41 | 42 | # Try to unrevoke the API key programmatically. 43 | api_key.revoked = False 44 | 45 | with pytest.raises(ValidationError): 46 | api_key.save() 47 | 48 | with pytest.raises(ValidationError): 49 | api_key.clean() 50 | 51 | 52 | @pytest.mark.parametrize( 53 | "expiry_date, has_expired", 54 | [(None, False), (NOW, True), (TOMORROW, False), (YESTERDAY, True)], 55 | ) 56 | def test_has_expired(expiry_date: dt.datetime, has_expired: bool) -> None: 57 | api_key, _ = APIKey.objects.create_key(name="test", expiry_date=expiry_date) 58 | assert api_key.has_expired is has_expired 59 | 60 | 61 | def test_custom_api_key_model() -> None: 62 | hero = Hero.objects.create() 63 | hero_api_key, generated_key = HeroAPIKey.objects.create_key(name="test", hero=hero) 64 | assert hero_api_key.is_valid(generated_key) 65 | assert hero_api_key.hero.id == hero.id 66 | assert hero.api_keys.first() == hero_api_key 67 | 68 | 69 | @pytest.mark.django_db 70 | def test_api_key_hash_upgrade() -> None: 71 | """Tests the hashing algo upgrade from Django's PW hashers to sha512.""" 72 | key_generator = APIKey.objects.key_generator 73 | 74 | api_key, generated_key = APIKey.objects.create_key(name="test") 75 | assert api_key.is_valid(generated_key) 76 | assert key_generator.using_preferred_hasher(api_key.hashed_key) 77 | 78 | # Use Django's built-in hashers, the old way of storing a key 79 | api_key.hashed_key = make_password(generated_key) 80 | api_key.save() 81 | 82 | # Simple sanity check to ensure the hash is still being checked 83 | # and that we aren't using the preferred hasher (using Django's slower hashers) 84 | assert not api_key.is_valid(key_generator.hash("invalid-key")) 85 | assert not key_generator.using_preferred_hasher(api_key.hashed_key) 86 | 87 | # After calling `is_valid`, the key has been upgraded to use the preferred hasher 88 | assert api_key.is_valid(generated_key) 89 | assert key_generator.using_preferred_hasher(api_key.hashed_key) 90 | 91 | 92 | @pytest.mark.django_db 93 | def test_api_key_manager_get_from_key() -> None: 94 | api_key, generated_key = APIKey.objects.create_key(name="test") 95 | retrieved_key = APIKey.objects.get_from_key(generated_key) 96 | assert retrieved_key == api_key 97 | 98 | 99 | @pytest.mark.django_db 100 | def test_api_key_manager_get_from_key_missing_key() -> None: 101 | with pytest.raises(APIKey.DoesNotExist): 102 | APIKey.objects.get_from_key("foobar") 103 | 104 | 105 | @pytest.mark.django_db 106 | def test_api_key_manager_get_from_key_invalid_key() -> None: 107 | api_key, generated_key = APIKey.objects.create_key(name="test") 108 | prefix, _, _ = generated_key.partition(".") 109 | invalid_key = f"{prefix}.foobar" 110 | with pytest.raises(APIKey.DoesNotExist): 111 | APIKey.objects.get_from_key(invalid_key) 112 | 113 | 114 | def test_api_key_str() -> None: 115 | _, generated_key = APIKey.objects.create_key(name="test") 116 | retrieved_key = APIKey.objects.get_from_key(generated_key) 117 | assert str(retrieved_key) == "test" 118 | -------------------------------------------------------------------------------- /tests/test_permissions.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Callable 3 | 4 | import pytest 5 | from django.test import RequestFactory, override_settings 6 | from rest_framework.decorators import api_view, permission_classes 7 | from rest_framework.request import Request 8 | from rest_framework.response import Response 9 | 10 | from rest_framework_api_key.models import APIKey 11 | from rest_framework_api_key.permissions import BaseHasAPIKey, HasAPIKey, KeyParser 12 | 13 | pytestmark = pytest.mark.django_db 14 | 15 | 16 | @api_view() 17 | @permission_classes([HasAPIKey]) 18 | def view(request: Request) -> Response: 19 | return Response() 20 | 21 | 22 | def test_if_valid_api_key_then_permission_granted(rf: RequestFactory) -> None: 23 | _, key = APIKey.objects.create_key(name="test") 24 | authorization = f"Api-Key {key}" 25 | request = rf.get("/test/", HTTP_AUTHORIZATION=authorization) 26 | 27 | response = view(request) 28 | assert response.status_code == 200 29 | 30 | 31 | def test_if_valid_api_key_custom_header_then_permission_granted( 32 | rf: RequestFactory, 33 | ) -> None: 34 | with override_settings(API_KEY_CUSTOM_HEADER="HTTP_X_API_KEY"): 35 | _, key = APIKey.objects.create_key(name="test") 36 | request = rf.get("/test/", HTTP_X_API_KEY=key) 37 | 38 | response = view(request) 39 | assert response.status_code == 200 40 | 41 | 42 | def test_if_no_api_key_then_permission_denied(rf: RequestFactory) -> None: 43 | request = rf.get("/test/") 44 | 45 | response = view(request) 46 | assert response.status_code == 403 47 | 48 | 49 | def _scramble_prefix(key: str) -> str: 50 | prefix, _, secret_key = key.partition(".") 51 | truncated_prefix = prefix[:-1] 52 | return truncated_prefix + "." + secret_key 53 | 54 | 55 | @pytest.mark.parametrize( 56 | "modifier", 57 | [ 58 | lambda _: "", 59 | lambda _: "abcd", 60 | lambda _: "foo.bar", 61 | lambda key: " " + key, 62 | lambda key: key.upper(), 63 | lambda key: key.lower(), 64 | lambda key: _scramble_prefix(key), 65 | ], 66 | ) 67 | def test_if_invalid_api_key_then_permission_denied( 68 | rf: RequestFactory, 69 | modifier: Callable[[str], str], 70 | ) -> None: 71 | _, key = APIKey.objects.create_key(name="test") 72 | authorization = f"Api-Key {modifier(key)}" 73 | request = rf.get("/test/", HTTP_AUTHORIZATION=authorization) 74 | 75 | response = view(request) 76 | assert response.status_code == 403 77 | 78 | 79 | @pytest.mark.parametrize( 80 | "authorization_fmt", 81 | [ 82 | pytest.param("X-Key {key}", id="wrong-scheme"), 83 | pytest.param("Api-Key:{key}", id="not-space-separated"), 84 | ], 85 | ) 86 | def test_if_malformed_authorization_then_permission_denied( 87 | rf: RequestFactory, authorization_fmt: str 88 | ) -> None: 89 | _, key = APIKey.objects.create_key(name="test") 90 | authorization = authorization_fmt.format(key=key) 91 | request = rf.get("/test/", HTTP_AUTHORIZATION=authorization) 92 | response = view(request) 93 | assert response.status_code == 403 94 | 95 | 96 | def test_if_invalid_api_key_custom_header_then_permission_denied( 97 | rf: RequestFactory, 98 | ) -> None: 99 | with override_settings(API_KEY_CUSTOM_HEADER="HTTP_X_API_KEY"): 100 | request = rf.get("/test/", HTTP_X_API_KEY="doesnotexist") 101 | 102 | response = view(request) 103 | assert response.status_code == 403 104 | 105 | 106 | def test_if_revoked_then_permission_denied(rf: RequestFactory) -> None: 107 | _, key = APIKey.objects.create_key(name="test", revoked=True) 108 | authorization = f"Api-Key {key}" 109 | request = rf.get("/test/", HTTP_AUTHORIZATION=authorization) 110 | 111 | response = view(request) 112 | assert response.status_code == 403 113 | 114 | 115 | NOW = dt.datetime.now() 116 | TOMORROW = NOW + dt.timedelta(days=1) 117 | TWO_DAYS_AGO = NOW - dt.timedelta(days=2) 118 | 119 | 120 | @pytest.mark.parametrize("expiry_date, ok", [(TOMORROW, True), (TWO_DAYS_AGO, False)]) 121 | def test_expiry_date(rf: RequestFactory, expiry_date: dt.datetime, ok: bool) -> None: 122 | _, key = APIKey.objects.create_key(name="test", expiry_date=expiry_date) 123 | authorization = f"Api-Key {key}" 124 | request = rf.get("/test/", HTTP_AUTHORIZATION=authorization) 125 | 126 | response = view(request) 127 | status_code = 200 if ok else 403 128 | assert response.status_code == status_code 129 | 130 | 131 | def test_keyparser_keyword_override(rf: RequestFactory) -> None: 132 | class BearerKeyParser(KeyParser): 133 | keyword = "Bearer" 134 | 135 | class BearerHasAPIKey(BaseHasAPIKey): 136 | model = APIKey 137 | key_parser = BearerKeyParser() 138 | 139 | @api_view() 140 | @permission_classes([BearerHasAPIKey]) 141 | def bearer_view(request: Request) -> Response: 142 | return Response() 143 | 144 | _, key = APIKey.objects.create_key(name="test") 145 | authorization = f"Bearer {key}" 146 | request = rf.get("/test/", HTTP_AUTHORIZATION=authorization) 147 | 148 | response = bearer_view(request) 149 | assert response.status_code == 200 150 | 151 | 152 | def test_keyparser_lookup_exact_keyword(rf: RequestFactory) -> None: 153 | wrong_key = "My-Special-Api-Key 12345" 154 | request = rf.get("/test/", HTTP_AUTHORIZATION=wrong_key) 155 | assert KeyParser().get(request) is None 156 | -------------------------------------------------------------------------------- /tests/test_permissions_combination.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth import get_user_model 3 | from django.test import RequestFactory 4 | from rest_framework.decorators import api_view, permission_classes 5 | from rest_framework.permissions import IsAuthenticated 6 | from rest_framework.request import Request 7 | from rest_framework.response import Response 8 | from rest_framework.test import force_authenticate 9 | 10 | from rest_framework_api_key.models import APIKey 11 | from rest_framework_api_key.permissions import HasAPIKey 12 | 13 | pytestmark = pytest.mark.django_db 14 | 15 | 16 | @api_view() 17 | @permission_classes([HasAPIKey | IsAuthenticated]) 18 | def view(request: Request) -> Response: 19 | return Response() 20 | 21 | 22 | def test_if_authenticated_and_no_api_key_then_permission_granted( 23 | rf: RequestFactory, 24 | ) -> None: 25 | user = get_user_model().objects.create_user(username="foo", password="bar") 26 | 27 | request = rf.get("/test/") 28 | force_authenticate(request, user) 29 | 30 | response = view(request) 31 | assert response.status_code == 200, response.data 32 | 33 | 34 | def test_if_authenticated_and_revoked_api_key_then_permission_granted( 35 | rf: RequestFactory, 36 | ) -> None: 37 | user = get_user_model().objects.create_user(username="foo", password="bar") 38 | 39 | _, key = APIKey.objects.create_key(name="test", revoked=True) 40 | authorization = f"Api-Key {key}" 41 | 42 | request = rf.get("/test/", HTTP_AUTHORIZATION=authorization) 43 | force_authenticate(request, user) 44 | 45 | response = view(request) 46 | assert response.status_code == 200, response.data 47 | -------------------------------------------------------------------------------- /tests/test_permissions_custom.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.test import RequestFactory 3 | from rest_framework.decorators import api_view, permission_classes 4 | from rest_framework.request import Request 5 | from rest_framework.response import Response 6 | from test_project.heroes.models import Hero, HeroAPIKey 7 | from test_project.heroes.permissions import HasHeroAPIKey 8 | 9 | from rest_framework_api_key.models import APIKey 10 | 11 | pytestmark = pytest.mark.django_db 12 | 13 | 14 | @api_view() 15 | @permission_classes([HasHeroAPIKey]) 16 | def view(request: Request) -> Response: 17 | return Response() 18 | 19 | 20 | def test_non_hero_api_key_denied(rf: RequestFactory) -> None: 21 | _, key = APIKey.objects.create_key(name="test") 22 | authorization = f"Api-Key {key}" 23 | request = rf.get("/test/", HTTP_AUTHORIZATION=authorization) 24 | 25 | response = view(request) 26 | assert response.status_code == 403 27 | 28 | 29 | def test_hero_api_key_granted(rf: RequestFactory) -> None: 30 | hero = Hero.objects.create() 31 | _, key = HeroAPIKey.objects.create_key(name="test", hero=hero) 32 | authorization = f"Api-Key {key}" 33 | request = rf.get("/test/", HTTP_AUTHORIZATION=authorization) 34 | 35 | response = view(request) 36 | assert response.status_code == 200 37 | 38 | 39 | def test_retired_hero_denied(rf: RequestFactory) -> None: 40 | hero = Hero.objects.create(retired=True) 41 | _, key = HeroAPIKey.objects.create_key(name="test", hero=hero) 42 | authorization = f"Api-Key {key}" 43 | request = rf.get("/test/", HTTP_AUTHORIZATION=authorization) 44 | 45 | response = view(request) 46 | assert response.status_code == 403 47 | -------------------------------------------------------------------------------- /tests/test_test_project.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rest_framework.test import APIClient 3 | from test_project.heroes.models import Hero 4 | from test_project.heroes.permissions import HeroAPIKey 5 | 6 | 7 | @pytest.mark.django_db 8 | def test_test_project_routes() -> None: 9 | batman = Hero.objects.create(name="Batman") 10 | _, key = HeroAPIKey.objects.create_key(name="test", hero=batman) 11 | headers = {"HTTP_AUTHORIZATION": f"Api-Key {key}"} 12 | 13 | client = APIClient() 14 | 15 | response = client.get("/api/public/", format="json") 16 | assert response.status_code == 200 17 | 18 | response = client.get("/api/protected/", format="json", **headers) 19 | assert response.status_code == 200 20 | 21 | response = client.get("/api/protected/object/", format="json", **headers) 22 | assert response.status_code == 200 23 | -------------------------------------------------------------------------------- /tools/install_django.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -ex 2 | 3 | PIP="$1" 4 | 5 | DJANGO_VERSION=${DJANGO_VERSION:-5.2} 6 | 7 | exec ${PIP} install django==$DJANGO_VERSION 8 | -------------------------------------------------------------------------------- /tools/makemigrations.py: -------------------------------------------------------------------------------- 1 | """A stand-alone equivalent of `python manage.py makemigrations`.""" 2 | import pathlib 3 | import sys 4 | 5 | import django 6 | from django.conf import settings 7 | from django.core.management import call_command 8 | 9 | root = pathlib.Path(__file__).parent.parent 10 | sys.path.append(str(root)) 11 | 12 | if __name__ == "__main__": 13 | APP = "rest_framework_api_key" 14 | settings.configure(INSTALLED_APPS=[APP, "test_project.heroes"]) 15 | django.setup() 16 | 17 | # For available options, see: 18 | # https://docs.djangoproject.com/en/3.0/ref/django-admin/#makemigrations 19 | options = sys.argv[1:] 20 | call_command("makemigrations", *options, APP, "heroes") 21 | --------------------------------------------------------------------------------