├── .codecov.yml ├── .coveragerc ├── .flake8 ├── .github └── workflows │ ├── gh-pages.yml │ └── test.yml ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── .python-version ├── CHANGELOG.md ├── CONTRIBUTING.md ├── CONTRIBUTORS ├── LICENSE ├── MANIFEST.in ├── README.md ├── docker-run-tests.sh ├── docs ├── auth.md ├── changelog.md ├── index.md ├── installation.md ├── settings.md ├── urls.md └── views.md ├── knox ├── __init__.py ├── admin.py ├── auth.py ├── crypto.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20150916_1425.py │ ├── 0003_auto_20150916_1526.py │ ├── 0004_authtoken_expires.py │ ├── 0005_authtoken_token_key.py │ ├── 0006_auto_20160818_0932.py │ ├── 0007_auto_20190111_0542.py │ ├── 0008_remove_authtoken_salt.py │ ├── 0009_extend_authtoken_field.py │ └── __init__.py ├── models.py ├── serializers.py ├── settings.py ├── signals.py ├── urls.py └── views.py ├── knox_project ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py ├── views.py └── wsgi.py ├── manage.py ├── mkdocs.sh ├── mkdocs.yml ├── setup.py ├── tests ├── __init__.py ├── test_crypto.py ├── test_models.py ├── test_settings.py └── test_views.py └── tox.ini /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: false 5 | tests: 6 | paths: tests 7 | informational: true 8 | knox: 9 | paths: knox 10 | informational: true 11 | patch: off -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = knox 4 | omit = 5 | */migrations/* 6 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 90 3 | max-complexity = 10 4 | exclude = *migrations* 5 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docs to GitHub Pages 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | branches: 9 | - develop 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: 3.x 23 | 24 | - name: Install dependencies 25 | run: pip install mkdocs-material 26 | 27 | - name: Build docs 28 | run: mkdocs build 29 | 30 | - name: Deploy to GitHub Pages 31 | uses: peaceiris/actions-gh-pages@v3 32 | with: 33 | personal_token: ${{ secrets.GITHUB_TOKEN }} 34 | publish_dir: ./site -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-22.04 8 | strategy: 9 | fail-fast: false 10 | max-parallel: 5 11 | matrix: 12 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Get pip cache dir 23 | id: pip-cache 24 | run: | 25 | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 26 | 27 | - name: Cache 28 | uses: actions/cache@v3 29 | with: 30 | path: ${{ steps.pip-cache.outputs.dir }} 31 | key: 32 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} 33 | restore-keys: | 34 | ${{ matrix.python-version }}-v1- 35 | 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | python -m pip install --upgrade tox tox-gh-actions coverage 40 | 41 | - name: Tox tests 42 | run: | 43 | tox -v 44 | 45 | - name: Generate coverage XML report 46 | run: coverage xml 47 | 48 | - name: Codecov 49 | uses: codecov/codecov-action@v3 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | db.sqlite3 59 | site/ 60 | 61 | # PyCharm Project 62 | .idea 63 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | combine_as_imports = true 3 | default_section = THIRDPARTY 4 | include_trailing_comma = true 5 | known_first_party = knox 6 | multi_line_output = 5 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/adamchainz/django-upgrade 3 | rev: 1.24.0 4 | hooks: 5 | - id: django-upgrade 6 | args: [--target-version, "4.2"] 7 | 8 | - repo: https://github.com/PyCQA/isort 9 | rev: 6.0.1 10 | hooks: 11 | - id: isort 12 | - repo: https://github.com/PyCQA/flake8 13 | rev: 7.2.0 14 | hooks: 15 | - id: flake8 16 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | django-rest-knox 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 5.0.3 2 | - Fix for Potential n+1 query detected on AuthToken.user 3 | 4 | ## 5.0.2 5 | - Implement AUTO_REFRESH_MAX_TTL to limit total token lifetime when AUTO_REFRESH = True 6 | 7 | ## 5.0.1 8 | - Fix migration: retrieve `TOKEN_MODEL` from `knox_settings` instead of Django settings. 9 | 10 | ## 5.0.0 11 | - Tokens created prior to this release will no longer work 12 | - Fix migration reverse flow, enable migrate 0 13 | - Various documentation fixes and improvements 14 | - Drop `cryptography` in favor of hashlib 15 | - Make custom AuthModel work 16 | - Token prefix can be set in the setttings 17 | - Drop support for Django 4.0 18 | - Add support for Dango 4.2, 5.0 and Python 3.11 and 3.12 19 | - Cleanup legacy Python 2.0 code 20 | - Fix isort, flake8 usage for Python 3.10 in the test suite 21 | - Update Github actions version 22 | - Upgrade markdown dependency 23 | - Get rid of the `six` library 24 | - Add custom login / logout response support 25 | - Join the jazzband organization 26 | - Add pre-commit hooks 27 | - Add tracking of tests code coverage 28 | - Fix migrations when used in condition with a custom DB 29 | - Improve typing 30 | - Use `self.authenticate_header()` in `authenticate()` method to get auth header prefix 31 | 32 | ## 4.2.0 33 | - compatibility with Python up to 3.10 and Django up to 4.0 34 | - integration with github CI instead of travis 35 | - Migration: "salt" field of model "AuthToken" is removed, WARNING: invalidates old tokens! 36 | 37 | ## 4.1.0 38 | 39 | - Expiry format now defaults to whatever is used Django REST framework 40 | - The behavior can be overridden via EXPIRY_DATETIME_FORMAT setting 41 | - Fully customizable expiry format via format_expiry_datetime 42 | - Fully customizable response payload via get_post_response_data 43 | 44 | 45 | ## 4.0.1 46 | 47 | - Fix for tox config to build Django 2.2 on python 3.6 48 | 49 | ## 4.0.0 50 | 51 | **BREAKING** This is a major release version because it 52 | breaks the existing API. 53 | Changes have been made to the `create()` method on the `AuthToken` model. 54 | It now returns the model instance and the raw `token` instead 55 | of just the `token` to allow the `expiry` field to be included in the 56 | success response. 57 | 58 | Model field of `AuthToken` has been renamed from `expires` to `expiry` 59 | to remain consistent across the code base. This patch requires you 60 | to run a migration. 61 | 62 | Depending on your usage you might have to adjust your code 63 | to fit these new changes. 64 | 65 | - `AuthToken` model field has been changed from `expires` to `expiry` 66 | - Successful login now always returns a `expiry` field for when the token expires 67 | 68 | ## 3.6.0 69 | 70 | - The user serializer for each `LoginView`is now dynamic 71 | 72 | ## 3.5.0 73 | 74 | - The context, token TTL and tokens per user settings in `LoginView` are now dynamic 75 | 76 | ## 3.4.0 77 | Our release cycle was broken since 3.1.5, hence you can not find the previous releases on pypi. We now fixed the problem. 78 | 79 | - Adds optional token limit 80 | - \#129, \#128 fixed 81 | - Changelog and Readme converted to markdown 82 | - Auth header prefix is now configurable 83 | - We ensure not to have flake8 errors in our code during our build 84 | - MIN_REFRESH_INTERVAL is now a configurable setting 85 | 86 | ## 3.3.1 87 | - Ensure compatibility with Django 2.1 up to Python 3.7 88 | 89 | ## 3.3.0 90 | 91 | - **Breaking changes**: Successful authentication **ONLY** returns 92 | `Token` object by default 93 | now.`USER_SERIALIZER` must be overridden to return more 94 | data. 95 | 96 | - Introduce new setting `MIN_REFRESH_INTERVAL` to configure the time 97 | interval (in seconds) to wait before a token is automatically refreshed. 98 | 99 | ## 3.2.1 100 | - Fix !111: Avoid knox failing if settings are not overwritten 101 | 102 | ## 3.2.0 103 | - Introduce new setting AUTO_REFRESH for controlling if token expiry time should be extended automatically 104 | 105 | ## 3.1.5 106 | - Make AuthTokenAdmin more compatible with big user tables 107 | - Extend docs regarding usage of Token Authentication as single authentication method. 108 | 109 | ## 3.1.4 110 | - Fix compatibility with django-rest-swagger (bad inheritance) 111 | 112 | ## 3.1.3 113 | - Avoid 500 error response for invalid-length token requests 114 | 115 | ## 3.1.2 116 | - restore compatibility with Python <2.7.7 117 | 118 | ## 3.1.1 119 | - use hmac.compare_digest instead of == for comparing hashes for more security 120 | 121 | ## 3.1.0 122 | - drop Django 1.8 support as djangorestframework did so too in v.3.7.0 123 | - build rest-knox on Django 1.11 and 2.0 124 | 125 | ## 3.0.3 126 | - drop using OpenSSL in favor of urandom 127 | 128 | ## 3.0.2 129 | - Add context to UserSerializer 130 | - improve docs 131 | 132 | ## 3.0.1 133 | - improved docs and readme 134 | - login response better supporting hyperlinked fields 135 | 136 | ## 3.0.0 137 | **Please be aware: updating to this version requires applying a database migration. All clients will need to reauthenticate.** 138 | 139 | - Big performance fix: Introduction of token_key field to avoid having to compare a login request's token against each and every token in the database (issue #21) 140 | - increased test coverage 141 | 142 | ## 2.2.2 143 | - Bugfix: invalid token length does no longer trigger a server error 144 | - Extending documentation 145 | 146 | ## 2.2.1 147 | **Please be aware: updating to his version requires applying a database migration** 148 | 149 | - Introducing token_key to avoid loop over all tokens on login-requests 150 | - Signals are sent on login/logout 151 | - Test for invalid token length 152 | - Cleanup in code and documentation 153 | 154 | - Bugfix: invalid token length does no longer trigger a server error 155 | - Extending documentation 156 | 157 | ## 2.2.0 158 | 159 | - Change to support python 2.7 160 | 161 | ## 2.0.0 162 | - Hashing of tokens on the server introduced. 163 | - Updating to this version will clean the AuthToken table. In real terms, this 164 | means all users will be forced to log in again. 165 | 166 | ## 1.1.0 167 | - `LoginView` changed to respect `DEFAULT_AUTHENTICATION_CLASSES` 168 | 169 | ## 1.0.0 170 | - Initial release 171 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | [![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) 2 | 3 | This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | James McMahon 2 | Pavel Sutyrin 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 James McMahon 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 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include CHANGELOG.md 3 | include LICENSE 4 | include CONTRIBUTORS 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-rest-knox 2 | ================ 3 | 4 | [![Jazzband](https://jazzband.co/static/img/badge.svg)](https://jazzband.co/) 5 | [![image](https://github.com/jazzband/django-rest-knox/workflows/Test/badge.svg?branch=develop)](https://github.com/jazzband/django-rest-knox/actions) 6 | 7 | Authentication module for Django rest auth. 8 | 9 | Knox provides easy-to-use authentication for [Django REST 10 | Framework](https://www.django-rest-framework.org/) The aim is to allow 11 | for common patterns in applications that are REST-based, with little 12 | extra effort; and to ensure that connections remain secure. 13 | 14 | Knox authentication is token-based, similar to the `TokenAuthentication` 15 | built into DRF. However, it overcomes some problems present in the 16 | default implementation: 17 | 18 | - DRF tokens are limited to one per user. This does not facilitate 19 | securely signing in from multiple devices, as the token is shared. 20 | It also requires *all* devices to be logged out if a server-side 21 | logout is required (i.e. the token is deleted). 22 | 23 | Knox provides one token per call to the login view - allowing each 24 | client to have its own token which is deleted on the server side 25 | when the client logs out. 26 | 27 | Knox also provides an option for a logged-in client to remove *all* 28 | tokens that the server has - forcing all clients to re-authenticate. 29 | 30 | - DRF tokens are stored unencrypted in the database. This would allow 31 | an attacker unrestricted access to an account with a token if the 32 | database were compromised. 33 | 34 | 35 | Knox tokens are only stored in a secure hash form (like a password). Even if the 36 | database were somehow stolen, an attacker would not be able to log 37 | in with the stolen credentials. 38 | 39 | - DRF tokens track their creation time, but have no inbuilt mechanism 40 | for tokens expiring. Knox tokens can have an expiry configured in 41 | the app settings (default is 10 hours.) 42 | 43 | More information can be found in the 44 | [Documentation](https://jazzband.github.io/django-rest-knox/) 45 | 46 | # Run the tests locally 47 | 48 | If you need to debug a test locally and if you have [docker](https://www.docker.com/) installed, 49 | simply run the ``./docker-run-tests.sh`` script and it will run the test suite in every Python / 50 | Django versions. 51 | 52 | You could also simply run regular ``tox`` in the root folder as well, but that would make testing the matrix of 53 | Python / Django versions a bit more tricky. 54 | 55 | # Work on the documentation 56 | 57 | Our documentation is generated by [Mkdocs](https://www.mkdocs.org). 58 | 59 | You can refer to their [documentation](https://www.mkdocs.org/user-guide/installation/) on how to install it locally. 60 | 61 | Another option is to use `mkdocs.sh` in this repository. 62 | It will run mkdocs in a [docker](https://www.docker.com/) container. 63 | 64 | Running the script without any params triggers the `serve` command. 65 | The server is exposed on localhost on port 8000. 66 | 67 | To configure the port the `serve` command will be exposing the server to, you 68 | can use the following env var: 69 | 70 | ``` 71 | MKDOCS_DEV_PORT="8080" 72 | ``` 73 | 74 | You can also pass any `mkdocs` command like this: 75 | 76 | ``` 77 | ./mkdocs build 78 | ./mkdocs --help 79 | ``` 80 | 81 | Check the [Mkdocs documentation](https://www.mkdocs.org/) for more. 82 | -------------------------------------------------------------------------------- /docker-run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | MOUNT_FOLDER=/app 3 | docker run --rm -it -v "$(pwd)":"$MOUNT_FOLDER" -w "$MOUNT_FOLDER" themattrix/tox 4 | -------------------------------------------------------------------------------- /docs/auth.md: -------------------------------------------------------------------------------- 1 | # Authentication `knox.auth` 2 | 3 | Knox provides one class to handle authentication. 4 | 5 | ## TokenAuthentication 6 | 7 | This works using [DRF's authentication system](https://www.django-rest-framework.org/api-guide/authentication/). 8 | 9 | Knox tokens should be generated using the provided views. 10 | Any `APIView` or `ViewSet` can be accessed using these tokens by adding `TokenAuthentication` 11 | to the View's `authentication_classes`. 12 | To authenticate, the `Authorization` header should be set on the request, with a 13 | value of the word `"Token"`, then a space, then the authentication token provided by 14 | `LoginView`. 15 | 16 | Example: 17 | ```python 18 | from rest_framework.permissions import IsAuthenticated 19 | from rest_framework.response import Response 20 | from rest_framework.views import APIView 21 | 22 | from knox.auth import TokenAuthentication 23 | 24 | class ExampleView(APIView): 25 | authentication_classes = (TokenAuthentication,) 26 | permission_classes = (IsAuthenticated,) 27 | 28 | def get(self, request, format=None): 29 | content = { 30 | 'foo': 'bar' 31 | } 32 | return Response(content) 33 | ``` 34 | 35 | Example auth header: 36 | 37 | ```javascript 38 | Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b9836F45E23A345 39 | ``` 40 | 41 | Tokens expire after a preset time. See settings. 42 | 43 | 44 | ### Global usage on all views 45 | 46 | You can activate TokenAuthentication on all your views by adding it to `REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]`. 47 | 48 | If it is your only default authentication class, remember to overwrite knox's LoginView, otherwise it'll not work, since the login view will require a authentication token to generate a new token, rendering it unusable. 49 | 50 | For instance, you can authenticate users using Basic Authentication by simply overwriting knox's LoginView and setting BasicAuthentication as one of the acceptable authentication classes, as follows: 51 | 52 | **views.py:** 53 | ```python 54 | from knox.views import LoginView as KnoxLoginView 55 | from rest_framework.authentication import BasicAuthentication 56 | 57 | class LoginView(KnoxLoginView): 58 | authentication_classes = [BasicAuthentication] 59 | ``` 60 | 61 | **urls.py:** 62 | ```python 63 | from knox import views as knox_views 64 | from yourapp.api.views import LoginView 65 | 66 | urlpatterns = [ 67 | path(r'login/', LoginView.as_view(), name='knox_login'), 68 | path(r'logout/', knox_views.LogoutView.as_view(), name='knox_logout'), 69 | path(r'logoutall/', knox_views.LogoutAllView.as_view(), name='knox_logoutall'), 70 | ] 71 | ``` 72 | 73 | You can use any number of authentication classes if you want to be able to authenticate using different methods (eg.: Basic and JSON) in the same view. Just be sure not to set TokenAuthentication as your only authentication class on the login view. 74 | 75 | If you decide to use Token Authentication as your only authentication class, you can overwrite knox's login view as such: 76 | 77 | **views.py:** 78 | ```python 79 | from django.contrib.auth import login 80 | 81 | from rest_framework import permissions 82 | from rest_framework.authtoken.serializers import AuthTokenSerializer 83 | from knox.views import LoginView as KnoxLoginView 84 | 85 | class LoginView(KnoxLoginView): 86 | permission_classes = (permissions.AllowAny,) 87 | 88 | def post(self, request, format=None): 89 | serializer = AuthTokenSerializer(data=request.data) 90 | serializer.is_valid(raise_exception=True) 91 | user = serializer.validated_data['user'] 92 | login(request, user) 93 | return super(LoginView, self).post(request, format=None) 94 | ``` 95 | 96 | **urls.py:** 97 | ```python 98 | from knox import views as knox_views 99 | from yourapp.api.views import LoginView 100 | 101 | urlpatterns = [ 102 | path(r'login/', LoginView.as_view(), name='knox_login'), 103 | path(r'logout/', knox_views.LogoutView.as_view(), name='knox_logout'), 104 | path(r'logoutall/', knox_views.LogoutAllView.as_view(), name='knox_logoutall'), 105 | ] 106 | ``` -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ../CHANGELOG.md -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Django-Rest-Knox 2 | Knox provides easy-to-use authentication for [Django REST Framework](https://www.django-rest-framework.org/) 3 | The aim is to allow for common patterns in applications that are REST based, 4 | with little extra effort; and to ensure that connections remain secure. 5 | 6 | Knox authentication is token based, similar to the `TokenAuthentication` built 7 | into DRF. However, it overcomes some problems present in the default implementation: 8 | 9 | - DRF tokens are limited to one per user. This does not facilitate securely 10 | signing in from multiple devices, as the token is shared. It also requires 11 | *all* devices to be logged out if a server-side logout is required (i.e. the 12 | token is deleted). 13 | 14 | Knox provides one token per call to the login view - allowing 15 | each client to have its own token which is deleted on the server side when the client 16 | logs out. Knox also provides an optional setting to limit the amount of tokens generated 17 | per user. 18 | 19 | Knox also provides an option for a logged in client to remove *all* tokens 20 | that the server has - forcing all clients to re-authenticate. 21 | 22 | - DRF tokens are stored unencrypted in the database. This would allow an attacker 23 | unrestricted access to an account with a token if the database were compromised. 24 | 25 | Knox tokens are only stored in an encrypted form. Even if the database were 26 | somehow stolen, an attacker would not be able to log in with the stolen 27 | credentials. 28 | 29 | - DRF tokens track their creation time, but have no inbuilt mechanism for tokens 30 | expiring. Knox tokens can have an expiry configured in the app settings (default is 31 | 10 hours.) 32 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Requirements 4 | 5 | Knox depends on pythons internal library `hashlib` to provide bindings to `OpenSSL` or uses 6 | an internal implementation of hashing algorithms for token generation. 7 | 8 | ## Installing Knox 9 | Knox should be installed with pip 10 | 11 | ```bash 12 | pip install django-rest-knox 13 | ``` 14 | 15 | ## Setup knox 16 | 17 | - Add `rest_framework` and `knox` to your `INSTALLED_APPS`, remove 18 | `rest_framework.authtoken` if you were using it. 19 | 20 | ```python 21 | INSTALLED_APPS = ( 22 | ... 23 | 'rest_framework', 24 | 'knox', 25 | ... 26 | ) 27 | ``` 28 | 29 | - Make knox's TokenAuthentication your default authentication class 30 | for django-rest-framework: 31 | 32 | ```python 33 | REST_FRAMEWORK = { 34 | 'DEFAULT_AUTHENTICATION_CLASSES': ('knox.auth.TokenAuthentication',), 35 | ... 36 | } 37 | ``` 38 | 39 | - Add the [knox url patterns](urls.md#urls-knoxurls) to your project. 40 | 41 | - If you set TokenAuthentication as the only default authentication class on the second step, [override knox's LoginView](auth.md#global-usage-on-all-views) to accept another authentication method and use it instead of knox's default login view. 42 | 43 | - Apply the migrations for the models. 44 | 45 | ```bash 46 | python manage.py migrate 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/settings.md: -------------------------------------------------------------------------------- 1 | # Settings `knox.settings` 2 | 3 | Settings in Knox are handled in a similar way to the rest framework settings. 4 | All settings are namespaced in the `'REST_KNOX'` setting. 5 | 6 | Example `settings.py` 7 | 8 | ```python 9 | #...snip... 10 | # These are the default values if none are set 11 | from datetime import timedelta 12 | from rest_framework.settings import api_settings 13 | 14 | KNOX_TOKEN_MODEL = 'knox.AuthToken' 15 | 16 | REST_KNOX = { 17 | 'SECURE_HASH_ALGORITHM': 'hashlib.sha512', 18 | 'AUTH_TOKEN_CHARACTER_LENGTH': 64, 19 | 'TOKEN_TTL': timedelta(hours=10), 20 | 'USER_SERIALIZER': 'knox.serializers.UserSerializer', 21 | 'TOKEN_LIMIT_PER_USER': None, 22 | 'AUTO_REFRESH': False, 23 | 'AUTO_REFRESH_MAX_TTL': None, 24 | 'MIN_REFRESH_INTERVAL': 60, 25 | 'AUTH_HEADER_PREFIX': 'Token', 26 | 'EXPIRY_DATETIME_FORMAT': api_settings.DATETIME_FORMAT, 27 | 'TOKEN_MODEL': 'knox.AuthToken', 28 | } 29 | #...snip... 30 | ``` 31 | 32 | ## KNOX_TOKEN_MODEL 33 | This is the variable used in the swappable dependency of the `AuthToken` model 34 | 35 | ## SECURE_HASH_ALGORITHM 36 | This is a reference to the class used to provide the hashing algorithm for 37 | token storage. 38 | 39 | *Do not change this unless you know what you are doing* 40 | 41 | By default, Knox uses SHA-512 to hash tokens in the database. 42 | 43 | `hashlib.sha3_512` is an acceptable alternative setting for production use. 44 | 45 | ### Tests 46 | SHA-512 and SHA3-512 are secure, however, they are slow. This should not be a 47 | problem for your users, but when testing it may be noticeable (as test cases tend 48 | to use many more requests much more quickly than real users). In testing scenarios 49 | it is acceptable to use `MD5` hashing (`hashlib.md5`). 50 | 51 | MD5 is **not secure** and must *never* be used in production sites. 52 | 53 | ## AUTH_TOKEN_CHARACTER_LENGTH 54 | This is the length of the token that will be sent to the client. By default it 55 | is set to 64 characters (this shouldn't need changing). 56 | 57 | ## TOKEN_TTL 58 | This is how long a token can exist before it expires. Expired tokens are automatically 59 | removed from the system. 60 | 61 | The setting should be set to an instance of `datetime.timedelta`. The default is 62 | 10 hours ()`timedelta(hours=10)`). 63 | 64 | Setting the TOKEN_TTL to `None` will create tokens that never expire. 65 | 66 | Warning: setting a 0 or negative timedelta will create tokens that instantly expire, 67 | the system will not prevent you setting this. 68 | 69 | ## TOKEN_LIMIT_PER_USER 70 | This allows you to control how many valid tokens can be issued per user. 71 | If the limit for valid tokens is reached, an error is returned at login. 72 | By default this option is disabled and set to `None` -- thus no limit. 73 | 74 | ## USER_SERIALIZER 75 | This is the reference to the class used to serialize the `User` objects when 76 | successfully returning from `LoginView`. The default is `knox.serializers.UserSerializer` 77 | 78 | ## AUTO_REFRESH 79 | This defines if the token expiry time is extended by TOKEN_TTL each time the token 80 | is used. 81 | 82 | ## AUTO_REFRESH_MAX_TTL 83 | When automatically extending token expiry time, limit the total token lifetime. If 84 | AUTO_REFRESH_MAX_TTL is set, then the token lifetime since the original creation date cannot 85 | exceed AUTO_REFRESH_MAX_TTL. 86 | 87 | ## MIN_REFRESH_INTERVAL 88 | This is the minimum time in seconds that needs to pass for the token expiry to be updated 89 | in the database. 90 | 91 | ## AUTH_HEADER_PREFIX 92 | This is the Authorization header value prefix. The default is `Token` 93 | 94 | ## EXPIRY_DATETIME_FORMAT 95 | This is the expiry datetime format returned in the login view. The default is the 96 | [DATETIME_FORMAT][DATETIME_FORMAT] of Django REST framework. May be any of `None`, `iso-8601` 97 | or a Python [strftime format][strftime format] string. 98 | 99 | ## TOKEN_MODEL 100 | This is the reference to the model used as `AuthToken`. We can define a custom `AuthToken` 101 | model in our project that extends `knox.AbstractAuthToken` and add our business logic to it. 102 | The default is `knox.AuthToken` 103 | 104 | [DATETIME_FORMAT]: https://www.django-rest-framework.org/api-guide/settings/#date-and-time-formatting 105 | [strftime format]: https://docs.python.org/3/library/time.html#time.strftime 106 | 107 | ## TOKEN_PREFIX 108 | This is the prefix for the generated token that is used in the Authorization header. The default is just an empty string. 109 | It can be up to `CONSTANTS.MAXIMUM_TOKEN_PREFIX_LENGTH` long. 110 | 111 | # Constants `knox.settings` 112 | Knox also provides some constants for information. These must not be changed in 113 | external code; they are used in the model definitions in knox and an error will 114 | be raised if there is an attempt to change them. 115 | 116 | ```python 117 | from knox.settings import CONSTANTS 118 | 119 | print(CONSTANTS.DIGEST_LENGTH) #=> 128 120 | ``` 121 | 122 | ## DIGEST_LENGTH 123 | This is the length of the digest that will be stored in the database for each token. 124 | 125 | ## MAXIMUM_TOKEN_PREFIX_LENGTH 126 | This is the maximum length of the token prefix. 127 | -------------------------------------------------------------------------------- /docs/urls.md: -------------------------------------------------------------------------------- 1 | #URLS `knox.urls` 2 | Knox provides a url config ready with its three default views routed. 3 | 4 | This can easily be included in your url config: 5 | 6 | ```python 7 | urlpatterns = [ 8 | #...snip... 9 | path(r'api/auth/', include('knox.urls')) 10 | #...snip... 11 | ] 12 | ``` 13 | **Note** It is important to use the string syntax and not try to import `knox.urls`, 14 | as the reference to the `User` model will cause the app to fail at import time. 15 | 16 | The views would then accessible as: 17 | 18 | - `/api/auth/login` -> `LoginView` 19 | - `/api/auth/logout` -> `LogoutView` 20 | - `/api/auth/logoutall` -> `LogoutAllView` 21 | 22 | they can also be looked up by name: 23 | 24 | ```python 25 | reverse('knox_login') 26 | reverse('knox_logout') 27 | reverse('knox_logoutall') 28 | ``` 29 | -------------------------------------------------------------------------------- /docs/views.md: -------------------------------------------------------------------------------- 1 | # Views `knox.views` 2 | Knox provides three views that handle token management for you. 3 | 4 | ## LoginView 5 | This view accepts only a post request with an empty body. 6 | 7 | The LoginView accepts the same sort of authentication as your Rest Framework 8 | `DEFAULT_AUTHENTICATION_CLASSES` setting. If this is not set, it defaults to 9 | `(SessionAuthentication, BasicAuthentication)`. 10 | 11 | LoginView was designed to work well with Basic authentication, or similar 12 | schemes. If you would like to use a different authentication scheme to the 13 | default, you can extend this class to provide your own value for 14 | `authentication_classes` 15 | 16 | It is possible to customize LoginView behaviour by overriding the following 17 | helper methods: 18 | - `get_context(self)`, to change the context passed to the `UserSerializer` 19 | - `get_token_ttl(self)`, to change the token ttl 20 | - `get_token_limit_per_user(self)`, to change the number of tokens available for a user 21 | - `get_user_serializer_class(self)`, to change the class used for serializing the user 22 | - `get_expiry_datetime_format(self)`, to change the datetime format used for expiry 23 | - `format_expiry_datetime(self, expiry)`, to format the expiry `datetime` object at your convenience 24 | - `create_token(self)`, to create the `AuthToken` instance at your convenience 25 | 26 | Finally, if none of these helper methods are sufficient, you can also override `get_post_response_data` 27 | to return a fully customized payload. 28 | 29 | ```python 30 | ...snip... 31 | def get_post_response_data(self, request, token, instance): 32 | UserSerializer = self.get_user_serializer_class() 33 | 34 | data = { 35 | 'expiry': self.format_expiry_datetime(instance.expiry), 36 | 'token': token 37 | } 38 | if UserSerializer is not None: 39 | data["user"] = UserSerializer( 40 | request.user, 41 | context=self.get_context() 42 | ).data 43 | return data 44 | ...snip... 45 | ``` 46 | 47 | --- 48 | When the endpoint authenticates a request, a json object will be returned 49 | containing the `token` key along with the actual value for the key by default. 50 | The success response also includes a `expiry` key with a timestamp for when 51 | the token expires. 52 | 53 | > *This is because `USER_SERIALIZER` setting is `None` by default.* 54 | 55 | If you wish to return custom data upon successful authentication 56 | like `first_name`, `last_name`, and `username` then the included `UserSerializer` 57 | class can be used inside `REST_KNOX` settings by adding `knox.serializers.UserSerializer` 58 | 59 | --- 60 | 61 | Obviously, if your app uses a custom user model that does not have these fields, 62 | a custom serializer must be used. 63 | 64 | ## LogoutView 65 | This view accepts only a post request with an empty body. 66 | It responds to Knox Token Authentication. On a successful request, 67 | the token used to authenticate is deleted from the 68 | system and can no longer be used to authenticate. 69 | 70 | By default, this endpoint returns a HTTP 204 response on a successful request. To 71 | customize this behavior, you can override the `get_post_response` method, for example 72 | to include a body in the logout response and/or to modify the status code: 73 | 74 | ```python 75 | ...snip... 76 | def get_post_response(self, request): 77 | return Response({"bye-bye": request.user.username}, status=200) 78 | ...snip... 79 | ``` 80 | 81 | ## LogoutAllView 82 | This view accepts only a post request with an empty body. It responds to Knox Token 83 | Authentication. 84 | On a successful request, a HTTP 204 is returned and the token used to authenticate, 85 | and *all other tokens* registered to the same `User` account, are deleted from the 86 | system and can no longer be used to authenticate. The success response can be modified 87 | like the `LogoutView` by overriding the `get_post_response` method. 88 | 89 | **Note** It is not recommended to alter the Logout views. They are designed 90 | specifically for token management, and to respond to Knox authentication. 91 | Modified forms of the class may cause unpredictable results. 92 | -------------------------------------------------------------------------------- /knox/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-rest-knox/1da41c91480025c6312efa4fd858b2f6f8ccfb8c/knox/__init__.py -------------------------------------------------------------------------------- /knox/admin.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import admin, messages 3 | from django.contrib.auth import get_user_model 4 | 5 | from knox import models 6 | from knox.settings import CONSTANTS 7 | 8 | 9 | class AuthTokenCreateForm(forms.ModelForm): 10 | 11 | def __init__(self, *args, **kwargs): 12 | super(AuthTokenCreateForm, self).__init__(*args, **kwargs) 13 | self.token = None 14 | 15 | class Meta: 16 | model = models.AuthToken 17 | fields = ['user', 'expiry'] 18 | 19 | def save(self, commit=True): 20 | obj = super(AuthTokenCreateForm, self).save(commit=False) 21 | digest, token = models.get_digest_token() 22 | obj.digest = digest 23 | obj.token_key = token[:CONSTANTS.TOKEN_KEY_LENGTH] 24 | self.token = token 25 | if commit: 26 | obj.save() 27 | obj.save_m2m() 28 | return obj 29 | 30 | 31 | @admin.register(models.AuthToken) 32 | class AuthTokenAdmin(admin.ModelAdmin): 33 | add_form = AuthTokenCreateForm 34 | list_display = ('digest', 'user', 'created', 'expiry',) 35 | # We dont know how a custom User model looks like, but is must have a USERNAME_FIELD 36 | search_fields = ['digest', 'token_key', 'user__'+get_user_model().USERNAME_FIELD] 37 | fields = () 38 | raw_id_fields = ('user',) 39 | 40 | def get_form(self, request, obj=None, **kwargs): 41 | defaults = {} 42 | if obj is None: 43 | defaults['form'] = self.add_form 44 | defaults.update(kwargs) 45 | return super(AuthTokenAdmin, self).get_form(request, obj, **defaults) 46 | 47 | def save_model(self, request, obj, form, change): 48 | if not change: 49 | self.message_user(request, "TOKEN " + form.token, messages.INFO) 50 | super(AuthTokenAdmin, self).save_model(request, obj, form, change) 51 | -------------------------------------------------------------------------------- /knox/auth.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import logging 3 | from hmac import compare_digest 4 | 5 | from django.utils import timezone 6 | from django.utils.translation import gettext_lazy as _ 7 | from rest_framework import exceptions 8 | from rest_framework.authentication import ( 9 | BaseAuthentication, get_authorization_header, 10 | ) 11 | 12 | from knox.crypto import hash_token 13 | from knox.models import get_token_model 14 | from knox.settings import CONSTANTS, knox_settings 15 | from knox.signals import token_expired 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class TokenAuthentication(BaseAuthentication): 21 | ''' 22 | This authentication scheme uses Knox AuthTokens for authentication. 23 | 24 | Similar to DRF's TokenAuthentication, it overrides a large amount of that 25 | authentication scheme to cope with the fact that Tokens are not stored 26 | in plaintext in the database 27 | 28 | If successful 29 | - `request.user` will be a django `User` instance 30 | - `request.auth` will be an `AuthToken` instance 31 | ''' 32 | 33 | def authenticate(self, request): 34 | auth = get_authorization_header(request).split() 35 | prefix = self.authenticate_header(request).encode() 36 | 37 | if not auth: 38 | return None 39 | if auth[0].lower() != prefix.lower(): 40 | # Authorization header is possibly for another backend 41 | return None 42 | if len(auth) == 1: 43 | msg = _('Invalid token header. No credentials provided.') 44 | raise exceptions.AuthenticationFailed(msg) 45 | elif len(auth) > 2: 46 | msg = _('Invalid token header. ' 47 | 'Token string should not contain spaces.') 48 | raise exceptions.AuthenticationFailed(msg) 49 | 50 | user, auth_token = self.authenticate_credentials(auth[1]) 51 | return (user, auth_token) 52 | 53 | def authenticate_credentials(self, token): 54 | ''' 55 | Due to the random nature of hashing a value, this must inspect 56 | each auth_token individually to find the correct one. 57 | 58 | Tokens that have expired will be deleted and skipped 59 | ''' 60 | msg = _('Invalid token.') 61 | token = token.decode("utf-8") 62 | for auth_token in get_token_model().objects.filter( 63 | token_key=token[:CONSTANTS.TOKEN_KEY_LENGTH]).select_related('user'): 64 | if self._cleanup_token(auth_token): 65 | continue 66 | 67 | try: 68 | digest = hash_token(token) 69 | except (TypeError, binascii.Error): 70 | raise exceptions.AuthenticationFailed(msg) 71 | if compare_digest(digest, auth_token.digest): 72 | if knox_settings.AUTO_REFRESH and auth_token.expiry: 73 | self.renew_token(auth_token) 74 | return self.validate_user(auth_token) 75 | raise exceptions.AuthenticationFailed(msg) 76 | 77 | def renew_token(self, auth_token) -> None: 78 | current_expiry = auth_token.expiry 79 | new_expiry = timezone.now() + knox_settings.TOKEN_TTL 80 | 81 | # Do not auto-renew tokens past AUTO_REFRESH_MAX_TTL. 82 | if knox_settings.AUTO_REFRESH_MAX_TTL is not None: 83 | max_expiry = auth_token.created + knox_settings.AUTO_REFRESH_MAX_TTL 84 | if new_expiry > max_expiry: 85 | new_expiry = max_expiry 86 | logger.info('Token renewal truncated due to AUTO_REFRESH_MAX_TTL.') 87 | 88 | auth_token.expiry = new_expiry 89 | 90 | # Throttle refreshing of token to avoid db writes 91 | delta = (new_expiry - current_expiry).total_seconds() 92 | if delta > knox_settings.MIN_REFRESH_INTERVAL: 93 | auth_token.save(update_fields=('expiry',)) 94 | 95 | def validate_user(self, auth_token): 96 | if not auth_token.user.is_active: 97 | raise exceptions.AuthenticationFailed( 98 | _('User inactive or deleted.')) 99 | return (auth_token.user, auth_token) 100 | 101 | def authenticate_header(self, request): 102 | return knox_settings.AUTH_HEADER_PREFIX 103 | 104 | def _cleanup_token(self, auth_token) -> bool: 105 | for other_token in auth_token.user.auth_token_set.all(): 106 | if other_token.digest != auth_token.digest and other_token.expiry: 107 | if other_token.expiry < timezone.now(): 108 | other_token.delete() 109 | username = other_token.user.get_username() 110 | token_expired.send(sender=self.__class__, 111 | username=username, source="other_token") 112 | if auth_token.expiry is not None: 113 | if auth_token.expiry < timezone.now(): 114 | username = auth_token.user.get_username() 115 | auth_token.delete() 116 | token_expired.send(sender=self.__class__, 117 | username=username, source="auth_token") 118 | return True 119 | return False 120 | -------------------------------------------------------------------------------- /knox/crypto.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | from os import urandom as generate_bytes 3 | 4 | from knox.settings import knox_settings 5 | 6 | hash_func = knox_settings.SECURE_HASH_ALGORITHM 7 | 8 | 9 | def create_token_string() -> str: 10 | """ 11 | Creates a secure random token string using hexadecimal encoding. 12 | 13 | The token length is determined by knox_settings.AUTH_TOKEN_CHARACTER_LENGTH. 14 | Since each byte is represented by 2 hexadecimal characters, the number of 15 | random bytes generated is half the desired character length. 16 | 17 | Returns: 18 | str: A hexadecimal string of length AUTH_TOKEN_CHARACTER_LENGTH containing 19 | random bytes. 20 | """ 21 | return binascii.hexlify( 22 | generate_bytes(int(knox_settings.AUTH_TOKEN_CHARACTER_LENGTH / 2)) 23 | ).decode() 24 | 25 | 26 | def make_hex_compatible(token: str) -> bytes: 27 | """ 28 | Converts a string token into a hex-compatible bytes object. 29 | 30 | We need to make sure that the token, that is send is hex-compatible. 31 | When a token prefix is used, we cannot guarantee that. 32 | 33 | Args: 34 | token (str): The token string to convert. 35 | 36 | Returns: 37 | bytes: The hex-compatible bytes representation of the token. 38 | """ 39 | return binascii.unhexlify(binascii.hexlify(bytes(token, 'utf-8'))) 40 | 41 | 42 | def hash_token(token: str) -> str: 43 | """ 44 | Calculates the hash of a token. 45 | 46 | Uses the hash algorithm specified in knox_settings.SECURE_HASH_ALGORITHM. 47 | The token is first converted to a hex-compatible format before hashing. 48 | 49 | Args: 50 | token (str): The token string to hash. 51 | 52 | Returns: 53 | str: The hexadecimal representation of the token's hash digest. 54 | 55 | Example: 56 | >>> hash_token("abc123") 57 | 'a123f...' # The actual hash will be longer 58 | """ 59 | digest = hash_func() 60 | digest.update(make_hex_compatible(token)) 61 | return digest.hexdigest() 62 | -------------------------------------------------------------------------------- /knox/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | from knox.settings import knox_settings 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | migrations.swappable_dependency(knox_settings.TOKEN_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='AuthToken', 20 | fields=[ 21 | ('key', models.CharField(max_length=64, serialize=False, primary_key=True)), 22 | ('created', models.DateTimeField(auto_now_add=True)), 23 | ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='auth_token_set', on_delete=models.CASCADE)), 24 | ], 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /knox/migrations/0002_auto_20150916_1425.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('knox', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.DeleteModel('AuthToken'), 16 | migrations.CreateModel( 17 | name='AuthToken', 18 | fields=[ 19 | ('digest', models.CharField(max_length=64, serialize=False, primary_key=True)), 20 | ('salt', models.CharField(max_length=16, serialize=False, unique=True)), 21 | ('created', models.DateTimeField(auto_now_add=True)), 22 | ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='auth_token_set', on_delete=models.CASCADE)), 23 | ],) 24 | ] 25 | -------------------------------------------------------------------------------- /knox/migrations/0003_auto_20150916_1526.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('knox', '0002_auto_20150916_1425'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='authtoken', 16 | name='digest', 17 | field=models.CharField(primary_key=True, serialize=False, max_length=128), 18 | ), 19 | migrations.AlterField( 20 | model_name='authtoken', 21 | name='salt', 22 | field=models.CharField(unique=True, max_length=16), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /knox/migrations/0004_authtoken_expires.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('knox', '0003_auto_20150916_1526'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='authtoken', 16 | name='expires', 17 | field=models.DateTimeField(null=True, blank=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /knox/migrations/0005_authtoken_token_key.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10 on 2016-08-18 09:23 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('knox', '0004_authtoken_expires'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='authtoken', 17 | name='token_key', 18 | field=models.CharField(blank=True, db_index=True, max_length=8, null=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /knox/migrations/0006_auto_20160818_0932.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10 on 2016-08-18 09:32 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | def cleanup_tokens(apps, schema_editor): 9 | AuthToken = apps.get_model('knox', 'AuthToken') 10 | AuthToken.objects.using(schema_editor.connection.alias).filter(token_key__isnull=True).delete() 11 | 12 | 13 | class Migration(migrations.Migration): 14 | 15 | dependencies = [ 16 | ('knox', '0005_authtoken_token_key'), 17 | ] 18 | 19 | operations = [ 20 | migrations.RunPython(cleanup_tokens, reverse_code=migrations.RunPython.noop), 21 | migrations.AlterField( 22 | model_name='authtoken', 23 | name='token_key', 24 | field=models.CharField(db_index=True, default='', max_length=8), 25 | preserve_default=False, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /knox/migrations/0007_auto_20190111_0542.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1 on 2019-01-11 05:42 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('knox', '0006_auto_20160818_0932'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='authtoken', 15 | old_name='expires', 16 | new_name='expiry', 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /knox/migrations/0008_remove_authtoken_salt.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-16 12:10 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('knox', '0007_auto_20190111_0542'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='authtoken', 15 | name='salt', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /knox/migrations/0009_extend_authtoken_field.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1rc1 on 2022-07-20 17:05 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("knox", "0008_remove_authtoken_salt"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="authtoken", 15 | name="token_key", 16 | field=models.CharField(db_index=True, max_length=25), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /knox/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-rest-knox/1da41c91480025c6312efa4fd858b2f6f8ccfb8c/knox/migrations/__init__.py -------------------------------------------------------------------------------- /knox/models.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.conf import settings 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.db import models 5 | from django.utils import timezone 6 | 7 | from knox import crypto 8 | from knox.settings import CONSTANTS, knox_settings 9 | 10 | sha = knox_settings.SECURE_HASH_ALGORITHM 11 | 12 | User = settings.AUTH_USER_MODEL 13 | 14 | 15 | def get_expiry(expiry): 16 | if expiry is not None: 17 | expiry = timezone.now() + expiry 18 | return expiry 19 | 20 | 21 | def get_digest_token(prefix=knox_settings.TOKEN_PREFIX): 22 | token = prefix + crypto.create_token_string() 23 | digest = crypto.hash_token(token) 24 | return digest, token 25 | 26 | 27 | class AuthTokenManager(models.Manager): 28 | def create( 29 | self, 30 | user, 31 | expiry=knox_settings.TOKEN_TTL, 32 | prefix=knox_settings.TOKEN_PREFIX, 33 | **kwargs 34 | ): 35 | 36 | digest, token = get_digest_token(prefix) 37 | if expiry is not None: 38 | expiry = timezone.now() + expiry 39 | instance = super().create( 40 | token_key=token[:CONSTANTS.TOKEN_KEY_LENGTH], digest=digest, 41 | user=user, expiry=expiry, **kwargs) 42 | return instance, token 43 | 44 | 45 | class AbstractAuthToken(models.Model): 46 | 47 | objects = AuthTokenManager() 48 | 49 | digest = models.CharField( 50 | max_length=CONSTANTS.DIGEST_LENGTH, primary_key=True) 51 | token_key = models.CharField( 52 | max_length=CONSTANTS.MAXIMUM_TOKEN_PREFIX_LENGTH + 53 | CONSTANTS.TOKEN_KEY_LENGTH, 54 | db_index=True 55 | ) 56 | user = models.ForeignKey(User, null=False, blank=False, 57 | related_name='auth_token_set', on_delete=models.CASCADE) 58 | created = models.DateTimeField(auto_now_add=True) 59 | expiry = models.DateTimeField(null=True, blank=True) 60 | 61 | class Meta: 62 | abstract = True 63 | 64 | def __str__(self) -> str: 65 | return f'{self.digest} : {self.user}' 66 | 67 | 68 | class AuthToken(AbstractAuthToken): 69 | class Meta: 70 | swappable = 'KNOX_TOKEN_MODEL' 71 | 72 | 73 | def get_token_model(): 74 | """ 75 | Return the AuthToken model that is active in this project. 76 | """ 77 | 78 | try: 79 | return apps.get_model(knox_settings.TOKEN_MODEL, require_ready=False) 80 | except ValueError: 81 | raise ImproperlyConfigured( 82 | "TOKEN_MODEL must be of the form 'app_label.model_name'" 83 | ) 84 | except LookupError: 85 | raise ImproperlyConfigured( 86 | "TOKEN_MODEL refers to model '%s' that has not been installed" 87 | % knox_settings.TOKEN_MODEL 88 | ) 89 | -------------------------------------------------------------------------------- /knox/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from rest_framework import serializers 3 | 4 | User = get_user_model() 5 | 6 | username_field = User.USERNAME_FIELD if hasattr(User, 'USERNAME_FIELD') else 'username' 7 | 8 | 9 | class UserSerializer(serializers.ModelSerializer): 10 | class Meta: 11 | model = User 12 | fields = (username_field,) 13 | -------------------------------------------------------------------------------- /knox/settings.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.conf import settings 4 | from django.core.signals import setting_changed 5 | from rest_framework.settings import APISettings, api_settings 6 | 7 | USER_SETTINGS = getattr(settings, 'REST_KNOX', None) 8 | 9 | DEFAULTS = { 10 | 'SECURE_HASH_ALGORITHM': 'hashlib.sha512', 11 | 'AUTH_TOKEN_CHARACTER_LENGTH': 64, 12 | 'TOKEN_TTL': timedelta(hours=10), 13 | 'USER_SERIALIZER': None, 14 | 'TOKEN_LIMIT_PER_USER': None, 15 | 'AUTO_REFRESH': False, 16 | 'AUTO_REFRESH_MAX_TTL': None, 17 | 'MIN_REFRESH_INTERVAL': 60, 18 | 'AUTH_HEADER_PREFIX': 'Token', 19 | 'EXPIRY_DATETIME_FORMAT': api_settings.DATETIME_FORMAT, 20 | 'TOKEN_MODEL': getattr(settings, 'KNOX_TOKEN_MODEL', 'knox.AuthToken'), 21 | 'TOKEN_PREFIX': '', 22 | } 23 | 24 | IMPORT_STRINGS = { 25 | 'SECURE_HASH_ALGORITHM', 26 | 'USER_SERIALIZER', 27 | } 28 | 29 | knox_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS) 30 | 31 | 32 | def reload_api_settings(*args, **kwargs): 33 | global knox_settings 34 | setting, value = kwargs['setting'], kwargs['value'] 35 | if setting == 'REST_KNOX': 36 | knox_settings = APISettings(value, DEFAULTS, IMPORT_STRINGS) 37 | if len(knox_settings.TOKEN_PREFIX) > CONSTANTS.MAXIMUM_TOKEN_PREFIX_LENGTH: 38 | raise ValueError("Illegal TOKEN_PREFIX length") 39 | 40 | 41 | setting_changed.connect(reload_api_settings) 42 | 43 | 44 | class CONSTANTS: 45 | ''' 46 | Constants cannot be changed at runtime 47 | ''' 48 | TOKEN_KEY_LENGTH = 15 49 | DIGEST_LENGTH = 128 50 | MAXIMUM_TOKEN_PREFIX_LENGTH = 10 51 | 52 | def __setattr__(self, *args, **kwargs): 53 | raise Exception(''' 54 | Constant values must NEVER be changed at runtime, as they are 55 | integral to the structure of database tables 56 | ''') 57 | 58 | 59 | CONSTANTS = CONSTANTS() # type: ignore 60 | -------------------------------------------------------------------------------- /knox/signals.py: -------------------------------------------------------------------------------- 1 | import django.dispatch 2 | 3 | token_expired = django.dispatch.Signal() 4 | -------------------------------------------------------------------------------- /knox/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from knox import views 4 | 5 | urlpatterns = [ 6 | path(r'login/', views.LoginView.as_view(), name='knox_login'), 7 | path(r'logout/', views.LogoutView.as_view(), name='knox_logout'), 8 | path(r'logoutall/', views.LogoutAllView.as_view(), name='knox_logoutall'), 9 | ] 10 | -------------------------------------------------------------------------------- /knox/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.signals import user_logged_in, user_logged_out 2 | from django.db.models import Q 3 | from django.utils import timezone 4 | from rest_framework import status 5 | from rest_framework.permissions import IsAuthenticated 6 | from rest_framework.response import Response 7 | from rest_framework.serializers import DateTimeField 8 | from rest_framework.settings import api_settings 9 | from rest_framework.views import APIView 10 | 11 | from knox.auth import TokenAuthentication 12 | from knox.models import get_token_model 13 | from knox.settings import knox_settings 14 | 15 | 16 | class LoginView(APIView): 17 | authentication_classes = api_settings.DEFAULT_AUTHENTICATION_CLASSES 18 | permission_classes = (IsAuthenticated,) 19 | 20 | def get_context(self): 21 | return {'request': self.request, 'format': self.format_kwarg, 'view': self} 22 | 23 | def get_token_ttl(self): 24 | return knox_settings.TOKEN_TTL 25 | 26 | def get_token_prefix(self): 27 | return knox_settings.TOKEN_PREFIX 28 | 29 | def get_token_limit_per_user(self): 30 | return knox_settings.TOKEN_LIMIT_PER_USER 31 | 32 | def get_user_serializer_class(self): 33 | return knox_settings.USER_SERIALIZER 34 | 35 | def get_expiry_datetime_format(self): 36 | return knox_settings.EXPIRY_DATETIME_FORMAT 37 | 38 | def format_expiry_datetime(self, expiry): 39 | datetime_format = self.get_expiry_datetime_format() 40 | return DateTimeField(format=datetime_format).to_representation(expiry) 41 | 42 | def create_token(self): 43 | token_prefix = self.get_token_prefix() 44 | return get_token_model().objects.create( 45 | user=self.request.user, expiry=self.get_token_ttl(), prefix=token_prefix 46 | ) 47 | 48 | def get_post_response_data(self, request, token, instance): 49 | UserSerializer = self.get_user_serializer_class() 50 | 51 | data = { 52 | 'expiry': self.format_expiry_datetime(instance.expiry), 53 | 'token': token 54 | } 55 | if UserSerializer is not None: 56 | data["user"] = UserSerializer( 57 | request.user, 58 | context=self.get_context() 59 | ).data 60 | return data 61 | 62 | def get_post_response(self, request, token, instance): 63 | data = self.get_post_response_data(request, token, instance) 64 | return Response(data) 65 | 66 | def post(self, request, format=None): 67 | token_limit_per_user = self.get_token_limit_per_user() 68 | if token_limit_per_user is not None: 69 | now = timezone.now() 70 | token = request.user.auth_token_set.filter( 71 | Q(expiry__gt=now) | Q(expiry__isnull=True) 72 | ) 73 | if token.count() >= token_limit_per_user: 74 | return Response( 75 | {"error": "Maximum amount of tokens allowed per user exceeded."}, 76 | status=status.HTTP_403_FORBIDDEN 77 | ) 78 | instance, token = self.create_token() 79 | user_logged_in.send(sender=request.user.__class__, 80 | request=request, user=request.user) 81 | return self.get_post_response(request, token, instance) 82 | 83 | 84 | class LogoutView(APIView): 85 | authentication_classes = (TokenAuthentication,) 86 | permission_classes = (IsAuthenticated,) 87 | 88 | def get_post_response(self, request): 89 | return Response(None, status=status.HTTP_204_NO_CONTENT) 90 | 91 | def post(self, request, format=None): 92 | request._auth.delete() 93 | user_logged_out.send(sender=request.user.__class__, 94 | request=request, user=request.user) 95 | return self.get_post_response(request) 96 | 97 | 98 | class LogoutAllView(APIView): 99 | ''' 100 | Log the user out of all sessions 101 | I.E. deletes all auth tokens for the user 102 | ''' 103 | authentication_classes = (TokenAuthentication,) 104 | permission_classes = (IsAuthenticated,) 105 | 106 | def get_post_response(self, request): 107 | return Response(None, status=status.HTTP_204_NO_CONTENT) 108 | 109 | def post(self, request, format=None): 110 | request.user.auth_token_set.all().delete() 111 | user_logged_out.send(sender=request.user.__class__, 112 | request=request, user=request.user) 113 | return self.get_post_response(request) 114 | -------------------------------------------------------------------------------- /knox_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-rest-knox/1da41c91480025c6312efa4fd858b2f6f8ccfb8c/knox_project/__init__.py -------------------------------------------------------------------------------- /knox_project/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for project project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "knox_project.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /knox_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test project for Django REST Knox 3 | """ 4 | 5 | from pathlib import Path 6 | 7 | BASE_DIR = Path(__file__).resolve().parent.parent 8 | SECRET_KEY = "i-am-a-super-secret-key" 9 | DEBUG = True 10 | ALLOWED_HOSTS = ["*"] 11 | 12 | # Application definition 13 | INSTALLED_APPS = [ 14 | "django.contrib.admin", 15 | "django.contrib.auth", 16 | "django.contrib.contenttypes", 17 | "django.contrib.sessions", 18 | "django.contrib.messages", 19 | "django.contrib.staticfiles", 20 | "rest_framework", 21 | "knox", 22 | ] 23 | 24 | MIDDLEWARE = [ 25 | "django.middleware.security.SecurityMiddleware", 26 | "django.contrib.sessions.middleware.SessionMiddleware", 27 | "django.middleware.common.CommonMiddleware", 28 | "django.middleware.csrf.CsrfViewMiddleware", 29 | "django.contrib.auth.middleware.AuthenticationMiddleware", 30 | "django.contrib.messages.middleware.MessageMiddleware", 31 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 32 | ] 33 | 34 | ROOT_URLCONF = "knox_project.urls" 35 | 36 | TEMPLATES = [ 37 | { 38 | "BACKEND": "django.template.backends.django.DjangoTemplates", 39 | "DIRS": [], 40 | "APP_DIRS": True, 41 | "OPTIONS": { 42 | "context_processors": [ 43 | "django.template.context_processors.debug", 44 | "django.template.context_processors.request", 45 | "django.contrib.auth.context_processors.auth", 46 | "django.contrib.messages.context_processors.messages", 47 | ], 48 | }, 49 | }, 50 | ] 51 | 52 | DATABASES = { 53 | "default": { 54 | "ENGINE": "django.db.backends.sqlite3", 55 | "NAME": "db.sqlite3", 56 | } 57 | } 58 | 59 | WSGI_APPLICATION = "knox_project.wsgi.application" 60 | 61 | LANGUAGE_CODE = "en-us" 62 | TIME_ZONE = "UTC" 63 | USE_I18N = True 64 | USE_TZ = True 65 | 66 | # Static files (CSS, JavaScript, Images) 67 | # https://docs.djangoproject.com/en/5.0/howto/static-files/ 68 | 69 | STATIC_URL = "static/" 70 | 71 | # Default primary key field type 72 | # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field 73 | 74 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 75 | 76 | # Django REST Knox settings 77 | REST_FRAMEWORK = { 78 | "DEFAULT_AUTHENTICATION_CLASSES": [ 79 | "rest_framework.authentication.BasicAuthentication", 80 | "rest_framework.authentication.SessionAuthentication", 81 | "knox.auth.TokenAuthentication", 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /knox_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.contrib import admin 4 | from django.urls import include, path 5 | 6 | from .views import RootView 7 | 8 | urlpatterns = [ 9 | path('admin/', admin.site.urls), 10 | path('api/', include('knox.urls')), 11 | path('api/', RootView.as_view(), name="api-root"), 12 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 13 | -------------------------------------------------------------------------------- /knox_project/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import IsAuthenticated 2 | from rest_framework.response import Response 3 | from rest_framework.views import APIView 4 | 5 | from knox.auth import TokenAuthentication 6 | 7 | 8 | class RootView(APIView): 9 | """ 10 | API Root View to test authentication. 11 | """ 12 | authentication_classes = (TokenAuthentication,) 13 | permission_classes = (IsAuthenticated,) 14 | 15 | def get(self, request): 16 | return Response("User is authenticated.") 17 | -------------------------------------------------------------------------------- /knox_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "knox_project.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "knox_project.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /mkdocs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | MOUNT_FOLDER=/app 4 | MKDOCS_DEV_ADDR=${MKDOCS_DEV_ADDR-"0.0.0.0"} 5 | MKDOCS_DEV_PORT=${MKDOCS_DEV_PORT-"8000"} 6 | 7 | docker run --rm -it \ 8 | -v $(pwd):$MOUNT_FOLDER \ 9 | -w $MOUNT_FOLDER \ 10 | -p $MKDOCS_DEV_PORT:$MKDOCS_DEV_PORT \ 11 | -e MKDOCS_DEV_ADDR="$MKDOCS_DEV_ADDR:$MKDOCS_DEV_PORT" \ 12 | squidfunk/mkdocs-material:latest $* 13 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Django-Rest-Knox 2 | repo_url: https://github.com/jazzband/django-rest-knox 3 | theme: readthedocs 4 | nav: 5 | - Home: 'index.md' 6 | - Installation: 'installation.md' 7 | - API Guide: 8 | - Views: 'views.md' 9 | - URLs: 'urls.md' 10 | - Authentication: 'auth.md' 11 | - Settings: 'settings.md' 12 | - Changelog: 'changelog.md' 13 | 14 | dev_addr: !!python/object/apply:os.getenv ["MKDOCS_DEV_ADDR"] 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Always prefer setuptools over distutils 2 | # To use a consistent encoding 3 | from codecs import open 4 | from os import path 5 | 6 | from setuptools import find_packages, setup 7 | 8 | here = path.abspath(path.dirname(__file__)) 9 | 10 | # Get the long description from the relevant file 11 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 12 | long_description = f.read() 13 | 14 | setup( 15 | name='django-rest-knox', 16 | 17 | # Versions should comply with PEP440. For a discussion on single-sourcing 18 | # the version across setup.py and the project code, see 19 | # https://packaging.python.org/en/latest/single_source_version.html 20 | version='5.0.2', 21 | description='Authentication for django rest framework', 22 | long_description=long_description, 23 | long_description_content_type='text/markdown', 24 | 25 | # The project's main homepage. 26 | url='https://github.com/jazzband/django-rest-knox', 27 | 28 | # Author details 29 | author='James McMahon', 30 | author_email='james1345@googlemail.com', 31 | 32 | # Choose your license 33 | license='MIT', 34 | 35 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 36 | classifiers=[ 37 | # How mature is this project? Common values are 38 | # 3 - Alpha 39 | # 4 - Beta 40 | # 5 - Production/Stable 41 | 'Development Status :: 5 - Production/Stable', 42 | 43 | # Indicate who your project is intended for 44 | 'Intended Audience :: Developers', 45 | 'Topic :: Internet :: WWW/HTTP :: Session', 46 | 47 | # Pick your license as you wish (should match "license" above) 48 | 'License :: OSI Approved :: MIT License', 49 | 'Programming Language :: Python :: 3', 50 | 'Programming Language :: Python :: 3.8', 51 | 'Programming Language :: Python :: 3.9', 52 | 'Programming Language :: Python :: 3.10', 53 | 'Programming Language :: Python :: 3.11', 54 | 'Programming Language :: Python :: 3.12', 55 | ], 56 | 57 | # What does your project relate to? 58 | keywords='django rest authentication login', 59 | 60 | # You can just specify the packages manually here if your project is 61 | # simple. Or you can use find_packages(). 62 | packages=find_packages( 63 | exclude=['contrib', 'docs', 'tests*', 'knox_project']), 64 | 65 | # List run-time dependencies here. These will be installed by pip when 66 | # your project is installed. For an analysis of "install_requires" vs pip's 67 | # requirements files see: 68 | # https://packaging.python.org/en/latest/requirements.html 69 | python_requires='>=3.8', 70 | install_requires=[ 71 | 'django>=4.2', 72 | 'djangorestframework', 73 | ], 74 | 75 | # List additional groups of dependencies here (e.g. development 76 | # dependencies). You can install these using the following syntax, 77 | # for example: 78 | # $ pip install -e .[dev,test] 79 | extras_require={ 80 | 'dev': [], 81 | 'test': [], 82 | }, 83 | 84 | # If there are data files included in your packages that need to be 85 | # installed, specify them here. If using Python 2.6 or less, then these 86 | # have to be included in MANIFEST.in as well. 87 | 88 | # Although 'package_data' is the preferred approach, in some case you may 89 | # need to place data files outside of your packages. See: 90 | # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files # noqa 91 | # In this case, 'data_file' will be installed into '/my_data' 92 | 93 | # To provide executable scripts, use entry points in preference to the 94 | # "scripts" keyword. Entry points provide cross-platform support and allow 95 | # pip to create the appropriate form of executable for the target platform. 96 | ) 97 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-rest-knox/1da41c91480025c6312efa4fd858b2f6f8ccfb8c/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_crypto.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.test import TestCase 4 | 5 | from knox.crypto import create_token_string, hash_token, make_hex_compatible 6 | from knox.settings import knox_settings 7 | 8 | 9 | class CryptoUtilsTestCase(TestCase): 10 | def test_create_token_string(self): 11 | """ 12 | Verify token string creation has correct length and contains only hex characters. 13 | """ 14 | with patch('os.urandom') as mock_urandom: 15 | mock_urandom.return_value = b'abcdef1234567890' 16 | expected_length = knox_settings.AUTH_TOKEN_CHARACTER_LENGTH 17 | token = create_token_string() 18 | self.assertEqual(len(token), expected_length) 19 | hex_chars = set('0123456789abcdef') 20 | self.assertTrue(all(c in hex_chars for c in token.lower())) 21 | 22 | def test_make_hex_compatible_with_valid_input(self): 23 | """ 24 | Ensure standard strings are correctly converted to hex-compatible bytes. 25 | """ 26 | test_token = "test123" 27 | result = make_hex_compatible(test_token) 28 | self.assertIsInstance(result, bytes) 29 | expected = b'test123' 30 | self.assertEqual(result, expected) 31 | 32 | def test_make_hex_compatible_with_empty_string(self): 33 | """ 34 | Verify empty string input returns empty bytes. 35 | """ 36 | test_token = "" 37 | result = make_hex_compatible(test_token) 38 | self.assertEqual(result, b'') 39 | 40 | def test_make_hex_compatible_with_special_characters(self): 41 | """ 42 | Check hex compatibility conversion handles special characters correctly. 43 | """ 44 | test_token = "test@#$%" 45 | result = make_hex_compatible(test_token) 46 | self.assertIsInstance(result, bytes) 47 | expected = b'test@#$%' 48 | self.assertEqual(result, expected) 49 | 50 | def test_hash_token_with_valid_token(self): 51 | """ 52 | Verify hash output is correct length and contains valid hex characters. 53 | """ 54 | test_token = "abcdef1234567890" 55 | result = hash_token(test_token) 56 | self.assertIsInstance(result, str) 57 | self.assertEqual(len(result), 128) 58 | hex_chars = set('0123456789abcdef') 59 | self.assertTrue(all(c in hex_chars for c in result.lower())) 60 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.test import TestCase 5 | from django.utils import timezone 6 | from freezegun import freeze_time 7 | 8 | from knox.models import AuthToken 9 | from knox.settings import CONSTANTS, knox_settings 10 | 11 | 12 | class AuthTokenTests(TestCase): 13 | """ 14 | Auth token model tests. 15 | """ 16 | 17 | def setUp(self): 18 | self.User = get_user_model() 19 | self.user = self.User.objects.create_user( 20 | username='testuser', 21 | password='testpass123' 22 | ) 23 | 24 | def test_token_creation(self): 25 | """ 26 | Test that tokens are created correctly with expected format. 27 | """ 28 | token_creation = timezone.now() 29 | with freeze_time(token_creation): 30 | instance, token = AuthToken.objects.create(user=self.user) 31 | self.assertIsNotNone(token) 32 | self.assertTrue(token.startswith(knox_settings.TOKEN_PREFIX)) 33 | self.assertEqual( 34 | len(instance.token_key), 35 | CONSTANTS.TOKEN_KEY_LENGTH, 36 | ) 37 | self.assertEqual(instance.user, self.user) 38 | self.assertEqual( 39 | instance.expiry, 40 | token_creation + timedelta(hours=10) 41 | ) 42 | 43 | def test_token_creation_with_expiry(self): 44 | """ 45 | Test token creation with explicit expiry time. 46 | """ 47 | expiry_time = timedelta(hours=10) 48 | before_creation = timezone.now() 49 | instance, _ = AuthToken.objects.create( 50 | user=self.user, 51 | expiry=expiry_time 52 | ) 53 | self.assertIsNotNone(instance.expiry) 54 | self.assertTrue(before_creation < instance.expiry) 55 | self.assertTrue( 56 | (instance.expiry - before_creation - expiry_time).total_seconds() < 1 57 | ) 58 | 59 | def test_token_string_representation(self): 60 | """ 61 | Test the string representation of AuthToken. 62 | """ 63 | instance, _ = AuthToken.objects.create(user=self.user) 64 | expected_str = f'{instance.digest} : {self.user}' 65 | self.assertEqual(str(instance), expected_str) 66 | 67 | def test_multiple_tokens_for_user(self): 68 | """ 69 | Test that a user can have multiple valid tokens. 70 | """ 71 | token1, _ = AuthToken.objects.create(user=self.user) 72 | token2, _ = AuthToken.objects.create(user=self.user) 73 | user_tokens = self.user.auth_token_set.all() 74 | self.assertEqual(user_tokens.count(), 2) 75 | self.assertNotEqual(token1.digest, token2.digest) 76 | 77 | def test_token_with_custom_prefix(self): 78 | """ 79 | Test token creation with custom prefix. 80 | """ 81 | custom_prefix = "TEST_" 82 | instance, token = AuthToken.objects.create( 83 | user=self.user, 84 | prefix=custom_prefix 85 | ) 86 | self.assertTrue(token.startswith(custom_prefix)) 87 | self.assertTrue(instance.token_key.startswith(custom_prefix)) 88 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from datetime import timedelta 3 | from unittest import mock 4 | 5 | from django.core.signals import setting_changed 6 | from django.test import override_settings 7 | 8 | from knox.settings import ( 9 | CONSTANTS, IMPORT_STRINGS, knox_settings, reload_api_settings, 10 | ) 11 | 12 | 13 | class TestKnoxSettings: 14 | @override_settings(REST_KNOX={ 15 | 'AUTH_TOKEN_CHARACTER_LENGTH': 32, 16 | 'TOKEN_TTL': timedelta(hours=5), 17 | 'AUTO_REFRESH': True, 18 | }) 19 | def test_override_settings(self): 20 | """ 21 | Test that settings can be overridden. 22 | """ 23 | assert knox_settings.AUTH_TOKEN_CHARACTER_LENGTH == 32 24 | assert knox_settings.TOKEN_TTL == timedelta(hours=5) 25 | assert knox_settings.AUTO_REFRESH is True 26 | # Default values should remain unchanged 27 | assert knox_settings.AUTH_HEADER_PREFIX == 'Token' 28 | 29 | def test_constants_immutability(self): 30 | """ 31 | Test that CONSTANTS cannot be modified. 32 | """ 33 | with self.assertRaises(Exception): 34 | CONSTANTS.TOKEN_KEY_LENGTH = 20 35 | 36 | with self.assertRaises(Exception): 37 | CONSTANTS.DIGEST_LENGTH = 256 38 | 39 | def test_constants_values(self): 40 | """ 41 | Test that CONSTANTS have correct values. 42 | """ 43 | assert CONSTANTS.TOKEN_KEY_LENGTH == 15 44 | assert CONSTANTS.DIGEST_LENGTH == 128 45 | assert CONSTANTS.MAXIMUM_TOKEN_PREFIX_LENGTH == 10 46 | 47 | def test_reload_api_settings(self): 48 | """ 49 | Test settings reload functionality. 50 | """ 51 | new_settings = { 52 | 'TOKEN_TTL': timedelta(hours=2), 53 | 'AUTH_HEADER_PREFIX': 'Bearer', 54 | } 55 | 56 | reload_api_settings( 57 | setting='REST_KNOX', 58 | value=new_settings 59 | ) 60 | 61 | assert knox_settings.TOKEN_TTL == timedelta(hours=2) 62 | assert knox_settings.AUTH_HEADER_PREFIX == 'Bearer' 63 | 64 | def test_token_prefix_length_validation(self): 65 | """ 66 | Test that TOKEN_PREFIX length is validated. 67 | """ 68 | with self.assertRaises(ValueError, match="Illegal TOKEN_PREFIX length"): 69 | reload_api_settings( 70 | setting='REST_KNOX', 71 | value={'TOKEN_PREFIX': 'x' * 11} # Exceeds MAXIMUM_TOKEN_PREFIX_LENGTH 72 | ) 73 | 74 | def test_import_strings(self): 75 | """ 76 | Test that import strings are properly handled. 77 | """ 78 | assert 'SECURE_HASH_ALGORITHM' in IMPORT_STRINGS 79 | assert 'USER_SERIALIZER' in IMPORT_STRINGS 80 | 81 | @override_settings(REST_KNOX={ 82 | 'SECURE_HASH_ALGORITHM': 'hashlib.md5' 83 | }) 84 | def test_hash_algorithm_import(self): 85 | """ 86 | Test that hash algorithm is properly imported. 87 | """ 88 | assert knox_settings.SECURE_HASH_ALGORITHM == hashlib.md5 89 | 90 | def test_setting_changed_signal(self): 91 | """ 92 | Test that setting_changed signal properly triggers reload. 93 | """ 94 | new_settings = { 95 | 'TOKEN_TTL': timedelta(hours=3), 96 | } 97 | 98 | setting_changed.send( 99 | sender=None, 100 | setting='REST_KNOX', 101 | value=new_settings 102 | ) 103 | 104 | assert knox_settings.TOKEN_TTL == timedelta(hours=3) 105 | 106 | @mock.patch('django.conf.settings') 107 | def test_custom_token_model(self, mock_settings): 108 | """ 109 | Test custom token model setting. 110 | """ 111 | custom_model = 'custom_app.CustomToken' 112 | mock_settings.KNOX_TOKEN_MODEL = custom_model 113 | 114 | # Reload settings 115 | reload_api_settings( 116 | setting='REST_KNOX', 117 | value={} 118 | ) 119 | 120 | assert knox_settings.TOKEN_MODEL == custom_model 121 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from datetime import datetime, timedelta 3 | from importlib import reload 4 | 5 | from django.contrib.auth import get_user_model 6 | from django.test import override_settings 7 | from django.urls import reverse 8 | from freezegun import freeze_time 9 | from rest_framework.exceptions import AuthenticationFailed 10 | from rest_framework.serializers import DateTimeField 11 | from rest_framework.test import APIRequestFactory, APITestCase as TestCase 12 | 13 | from knox import auth, crypto, views 14 | from knox.auth import TokenAuthentication 15 | from knox.models import AuthToken 16 | from knox.serializers import UserSerializer 17 | from knox.settings import CONSTANTS, knox_settings 18 | from knox.signals import token_expired 19 | 20 | User = get_user_model() 21 | root_url = reverse('api-root') 22 | 23 | 24 | def get_basic_auth_header(username, password): 25 | """ 26 | Create a basic auth header (test helper). 27 | """ 28 | return 'Basic %s' % base64.b64encode( 29 | (f'{username}:{password}').encode('ascii')).decode() 30 | 31 | 32 | auto_refresh_knox = knox_settings.defaults.copy() 33 | auto_refresh_knox["AUTO_REFRESH"] = True 34 | 35 | auto_refresh_max_ttl_knox = auto_refresh_knox.copy() 36 | auto_refresh_max_ttl_knox["AUTO_REFRESH_MAX_TTL"] = timedelta(hours=12) 37 | 38 | token_user_limit_knox = knox_settings.defaults.copy() 39 | token_user_limit_knox["TOKEN_LIMIT_PER_USER"] = 10 40 | 41 | user_serializer_knox = knox_settings.defaults.copy() 42 | user_serializer_knox["USER_SERIALIZER"] = UserSerializer 43 | 44 | auth_header_prefix_knox = knox_settings.defaults.copy() 45 | auth_header_prefix_knox["AUTH_HEADER_PREFIX"] = 'Baerer' 46 | 47 | token_no_expiration_knox = knox_settings.defaults.copy() 48 | token_no_expiration_knox["TOKEN_TTL"] = None 49 | 50 | EXPIRY_DATETIME_FORMAT = '%H:%M %d/%m/%y' 51 | expiry_datetime_format_knox = knox_settings.defaults.copy() 52 | expiry_datetime_format_knox["EXPIRY_DATETIME_FORMAT"] = EXPIRY_DATETIME_FORMAT 53 | 54 | token_prefix = "TEST_" 55 | token_prefix_knox = knox_settings.defaults.copy() 56 | token_prefix_knox["TOKEN_PREFIX"] = token_prefix 57 | 58 | token_prefix_too_long = "a" * CONSTANTS.MAXIMUM_TOKEN_PREFIX_LENGTH + "a" 59 | token_prefix_too_long_knox = knox_settings.defaults.copy() 60 | token_prefix_too_long_knox["TOKEN_PREFIX"] = token_prefix_too_long 61 | 62 | 63 | class BaseTestCase(TestCase): 64 | def setUp(self): 65 | """ 66 | Creates test users. 67 | """ 68 | self.username = 'john.doe' 69 | self.email = 'john.doe@example.com' 70 | self.password = 'hunter2' 71 | self.user = User.objects.create_user(self.username, self.email, self.password) 72 | 73 | self.username2 = 'jane.doe' 74 | self.email2 = 'jane.doe@example.com' 75 | self.password2 = 'hunter2' 76 | self.user2 = User.objects.create_user(self.username2, self.email2, self.password2) 77 | 78 | 79 | class LoginViewTestCase(BaseTestCase): 80 | """ 81 | Tests the functionality of the login view. 82 | """ 83 | 84 | def test_login_creates_keys(self): 85 | self.assertEqual(AuthToken.objects.count(), 0) 86 | url = reverse('knox_login') 87 | self.client.credentials( 88 | HTTP_AUTHORIZATION=get_basic_auth_header(self.username, self.password)) 89 | 90 | for _ in range(5): 91 | self.client.post(url, {}, format='json') 92 | self.assertEqual(AuthToken.objects.count(), 5) 93 | self.assertTrue(all(e.token_key for e in AuthToken.objects.all())) 94 | 95 | def test_login_returns_serialized_token(self): 96 | self.assertEqual(AuthToken.objects.count(), 0) 97 | url = reverse('knox_login') 98 | self.client.credentials( 99 | HTTP_AUTHORIZATION=get_basic_auth_header(self.username, self.password) 100 | ) 101 | response = self.client.post(url, {}, format='json') 102 | self.assertEqual(response.status_code, 200) 103 | self.assertEqual(knox_settings.USER_SERIALIZER, None) 104 | self.assertIn('token', response.data) 105 | username_field = self.user.USERNAME_FIELD 106 | self.assertNotIn(username_field, response.data) 107 | 108 | def test_login_returns_serialized_token_and_username_field(self): 109 | with override_settings(REST_KNOX=user_serializer_knox): 110 | reload(views) 111 | self.assertEqual(AuthToken.objects.count(), 0) 112 | url = reverse('knox_login') 113 | self.client.credentials( 114 | HTTP_AUTHORIZATION=get_basic_auth_header(self.username, self.password) 115 | ) 116 | response = self.client.post(url, {}, format='json') 117 | self.assertEqual(user_serializer_knox["USER_SERIALIZER"], UserSerializer) 118 | (views) 119 | self.assertEqual(response.status_code, 200) 120 | self.assertIn('token', response.data) 121 | username_field = self.user.USERNAME_FIELD 122 | self.assertIn('user', response.data) 123 | self.assertIn(username_field, response.data['user']) 124 | 125 | def test_login_returns_configured_expiry_datetime_format(self): 126 | with override_settings(REST_KNOX=expiry_datetime_format_knox): 127 | reload(views) 128 | self.assertEqual(AuthToken.objects.count(), 0) 129 | url = reverse('knox_login') 130 | self.client.credentials( 131 | HTTP_AUTHORIZATION=get_basic_auth_header(self.username, self.password) 132 | ) 133 | response = self.client.post(url, {}, format='json') 134 | self.assertEqual( 135 | expiry_datetime_format_knox["EXPIRY_DATETIME_FORMAT"], 136 | EXPIRY_DATETIME_FORMAT 137 | ) 138 | reload(views) 139 | self.assertEqual(response.status_code, 200) 140 | self.assertIn('token', response.data) 141 | self.assertNotIn('user', response.data) 142 | self.assertEqual( 143 | response.data['expiry'], 144 | DateTimeField(format=EXPIRY_DATETIME_FORMAT).to_representation( 145 | AuthToken.objects.first().expiry 146 | ) 147 | ) 148 | 149 | 150 | class LogoutViewsTestCase(BaseTestCase): 151 | """ 152 | Tests the functionality of the logout views. 153 | """ 154 | 155 | def test_logout_deletes_keys(self): 156 | self.assertEqual(AuthToken.objects.count(), 0) 157 | for _ in range(2): 158 | instance, token = AuthToken.objects.create(user=self.user) 159 | self.assertEqual(AuthToken.objects.count(), 2) 160 | 161 | url = reverse('knox_logout') 162 | self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) 163 | self.client.post(url, {}, format='json') 164 | self.assertEqual(AuthToken.objects.count(), 1, 165 | 'other tokens should remain after logout') 166 | 167 | def test_logout_all_deletes_keys(self): 168 | self.assertEqual(AuthToken.objects.count(), 0) 169 | for _ in range(10): 170 | instance, token = AuthToken.objects.create(user=self.user) 171 | self.assertEqual(AuthToken.objects.count(), 10) 172 | 173 | url = reverse('knox_logoutall') 174 | self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) 175 | self.client.post(url, {}, format='json') 176 | self.assertEqual(AuthToken.objects.count(), 0) 177 | 178 | def test_logout_all_deletes_only_targets_keys(self): 179 | self.assertEqual(AuthToken.objects.count(), 0) 180 | for _ in range(10): 181 | _, token = AuthToken.objects.create(user=self.user) 182 | AuthToken.objects.create(user=self.user2) 183 | self.assertEqual(AuthToken.objects.count(), 20) 184 | 185 | url = reverse('knox_logoutall') 186 | self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) 187 | self.client.post(url, {}, format='json') 188 | self.assertEqual(AuthToken.objects.count(), 10, 189 | 'tokens from other users should not be affected by logout all') 190 | 191 | 192 | class TokenAuthenticationTestCase(BaseTestCase): 193 | """ 194 | Tests the functionality of the `TokenAuthentication` class. 195 | """ 196 | 197 | def test_expired_tokens_login_fails(self): 198 | self.assertEqual(AuthToken.objects.count(), 0) 199 | instance, token = AuthToken.objects.create( 200 | user=self.user, expiry=timedelta(seconds=-1)) 201 | self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) 202 | response = self.client.post(root_url, {}, format='json') 203 | self.assertEqual(response.status_code, 401) 204 | self.assertEqual(response.data, {"detail": "Invalid token."}) 205 | 206 | def test_expired_tokens_deleted(self): 207 | self.assertEqual(AuthToken.objects.count(), 0) 208 | for _ in range(10): 209 | # -1 TTL gives an expired token 210 | _, token = AuthToken.objects.create( 211 | user=self.user, expiry=timedelta(seconds=-1)) 212 | self.assertEqual(AuthToken.objects.count(), 10) 213 | 214 | # Attempting a single logout should delete all tokens 215 | url = reverse('knox_logout') 216 | self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) 217 | self.client.post(url, {}, format='json') 218 | self.assertEqual(AuthToken.objects.count(), 0) 219 | 220 | def test_update_token_key(self): 221 | self.assertEqual(AuthToken.objects.count(), 0) 222 | _, token = AuthToken.objects.create(self.user) 223 | rf = APIRequestFactory() 224 | request = rf.get('/') 225 | request.META = {'HTTP_AUTHORIZATION': f'Token {token}'} 226 | (self.user, auth_token) = TokenAuthentication().authenticate(request) 227 | self.assertEqual( 228 | token[:CONSTANTS.TOKEN_KEY_LENGTH], 229 | auth_token.token_key, 230 | ) 231 | 232 | def test_token_auth_n_plus_one(self): 233 | self.assertEqual(AuthToken.objects.count(), 0) 234 | _, token = AuthToken.objects.create(self.user) 235 | rf = APIRequestFactory() 236 | request = rf.get('/') 237 | request.META = {'HTTP_AUTHORIZATION': f'Token {token}'} 238 | with self.assertNumQueries(2): 239 | (self.user, auth_token) = TokenAuthentication().authenticate(request) 240 | self.assertEqual( 241 | token[:CONSTANTS.TOKEN_KEY_LENGTH], 242 | auth_token.token_key, 243 | ) 244 | 245 | def test_authorization_header_empty(self): 246 | rf = APIRequestFactory() 247 | request = rf.get('/') 248 | request.META = {'HTTP_AUTHORIZATION': ''} 249 | self.assertEqual(TokenAuthentication().authenticate(request), None) 250 | 251 | def test_authorization_header_prefix_only(self): 252 | rf = APIRequestFactory() 253 | request = rf.get('/') 254 | request.META = {'HTTP_AUTHORIZATION': 'Token'} 255 | with self.assertRaises(AuthenticationFailed) as err: 256 | (self.user, auth_token) = TokenAuthentication().authenticate(request) 257 | self.assertIn( 258 | 'Invalid token header. No credentials provided.', 259 | str(err.exception), 260 | ) 261 | 262 | def test_authorization_header_spaces_in_token_string(self): 263 | rf = APIRequestFactory() 264 | request = rf.get('/') 265 | request.META = {'HTTP_AUTHORIZATION': 'Token wordone wordtwo'} 266 | with self.assertRaises(AuthenticationFailed) as err: 267 | (self.user, auth_token) = TokenAuthentication().authenticate(request) 268 | self.assertIn( 269 | 'Invalid token header. Token string should not contain spaces.', 270 | str(err.exception), 271 | ) 272 | 273 | def test_invalid_token_length_returns_401_code(self): 274 | invalid_token = "1" * (CONSTANTS.TOKEN_KEY_LENGTH - 1) 275 | self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % invalid_token)) 276 | response = self.client.post(root_url, {}, format='json') 277 | self.assertEqual(response.status_code, 401) 278 | self.assertEqual(response.data, {"detail": "Invalid token."}) 279 | 280 | def test_invalid_odd_length_token_returns_401_code(self): 281 | _, token = AuthToken.objects.create(self.user) 282 | odd_length_token = token + '1' 283 | self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % odd_length_token)) 284 | response = self.client.post(root_url, {}, format='json') 285 | self.assertEqual(response.status_code, 401) 286 | self.assertEqual(response.data, {"detail": "Invalid token."}) 287 | 288 | def test_token_expiry_is_extended_with_auto_refresh_activated(self): 289 | """ 290 | """ 291 | ttl = knox_settings.TOKEN_TTL 292 | original_time = datetime(2018, 7, 25, 0, 0, 0, 0) 293 | 294 | with freeze_time(original_time): 295 | _, token = AuthToken.objects.create(user=self.user) 296 | 297 | self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) 298 | five_hours_later = original_time + timedelta(hours=5) 299 | with override_settings(REST_KNOX=auto_refresh_knox): 300 | reload(auth) # necessary to reload settings in core code 301 | with freeze_time(five_hours_later): 302 | response = self.client.get(root_url, {}, format='json') 303 | reload(auth) 304 | self.assertEqual(response.status_code, 200) 305 | 306 | # original expiry date was extended: 307 | new_expiry = AuthToken.objects.get().expiry 308 | expected_expiry = original_time + ttl + timedelta(hours=5) 309 | self.assertEqual(new_expiry.replace(tzinfo=None), expected_expiry, 310 | "Expiry time should have been extended to {} but is {}." 311 | .format(expected_expiry, new_expiry)) 312 | 313 | # token works after original expiry: 314 | after_original_expiry = original_time + ttl + timedelta(hours=1) 315 | with freeze_time(after_original_expiry): 316 | response = self.client.get(root_url, {}, format='json') 317 | self.assertEqual(response.status_code, 200) 318 | 319 | # token does not work after new expiry: 320 | new_expiry = AuthToken.objects.get().expiry 321 | with freeze_time(new_expiry + timedelta(seconds=1)): 322 | response = self.client.get(root_url, {}, format='json') 323 | self.assertEqual(response.status_code, 401) 324 | 325 | def test_token_expiry_is_not_extended_with_auto_refresh_deactivated(self): 326 | self.assertEqual(knox_settings.AUTO_REFRESH, False) 327 | self.assertEqual(knox_settings.TOKEN_TTL, timedelta(hours=10)) 328 | 329 | now = datetime.now() 330 | with freeze_time(now): 331 | instance, token = AuthToken.objects.create(user=self.user) 332 | 333 | original_expiry = AuthToken.objects.get().expiry 334 | 335 | self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) 336 | with freeze_time(now + timedelta(hours=1)): 337 | response = self.client.get(root_url, {}, format='json') 338 | 339 | self.assertEqual(response.status_code, 200) 340 | self.assertEqual(original_expiry, AuthToken.objects.get().expiry) 341 | 342 | def test_token_expiry_is_not_extended_within_MIN_REFRESH_INTERVAL(self): 343 | now = datetime.now() 344 | with freeze_time(now): 345 | instance, token = AuthToken.objects.create(user=self.user) 346 | 347 | original_expiry = AuthToken.objects.get().expiry 348 | 349 | self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) 350 | in_min_interval = now + timedelta(seconds=knox_settings.MIN_REFRESH_INTERVAL - 10) 351 | with override_settings(REST_KNOX=auto_refresh_knox): 352 | reload(auth) # necessary to reload settings in core code 353 | with freeze_time(in_min_interval): 354 | response = self.client.get(root_url, {}, format='json') 355 | reload(auth) # necessary to reload settings in core code 356 | 357 | self.assertEqual(response.status_code, 200) 358 | self.assertEqual(original_expiry, AuthToken.objects.get().expiry) 359 | 360 | def test_token_expiry_is_not_extended_past_max_ttl(self): 361 | ttl = knox_settings.TOKEN_TTL 362 | self.assertEqual(ttl, timedelta(hours=10)) 363 | original_time = datetime(2018, 7, 25, 0, 0, 0, 0) 364 | 365 | with freeze_time(original_time): 366 | instance, token = AuthToken.objects.create(user=self.user) 367 | 368 | self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) 369 | five_hours_later = original_time + timedelta(hours=5) 370 | with override_settings(REST_KNOX=auto_refresh_max_ttl_knox): 371 | reload(auth) # necessary to reload settings in core code 372 | self.assertEqual(auth.knox_settings.AUTO_REFRESH, True) 373 | self.assertEqual(auth.knox_settings.AUTO_REFRESH_MAX_TTL, timedelta(hours=12)) 374 | with freeze_time(five_hours_later): 375 | response = self.client.get(root_url, {}, format='json') 376 | reload(auth) # necessary to reload settings in core code 377 | self.assertEqual(response.status_code, 200) 378 | 379 | # original expiry date was extended, but not past max_ttl: 380 | new_expiry = AuthToken.objects.get().expiry 381 | expected_expiry = original_time + timedelta(hours=12) 382 | self.assertEqual(new_expiry.replace(tzinfo=None), expected_expiry, 383 | "Expiry time should have been extended to {} but is {}." 384 | .format(expected_expiry, new_expiry)) 385 | 386 | with freeze_time(expected_expiry + timedelta(seconds=1)): 387 | response = self.client.get(root_url, {}, format='json') 388 | self.assertEqual(response.status_code, 401) 389 | 390 | def test_expiry_signals(self): 391 | self.signal_was_called = False 392 | 393 | def handler(sender, username, **kwargs): 394 | self.signal_was_called = True 395 | 396 | token_expired.connect(handler) 397 | 398 | _, token = AuthToken.objects.create( 399 | user=self.user, 400 | expiry=timedelta(seconds=-1), 401 | ) 402 | self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) 403 | self.client.post(root_url, {}, format='json') 404 | 405 | self.assertTrue(self.signal_was_called) 406 | 407 | def test_exceed_token_amount_per_user(self): 408 | with override_settings(REST_KNOX=token_user_limit_knox): 409 | reload(views) 410 | for _ in range(5): 411 | AuthToken.objects.create(user=self.user) 412 | for _ in range(5): 413 | AuthToken.objects.create(user=self.user, expiry=None) 414 | url = reverse('knox_login') 415 | self.client.credentials( 416 | HTTP_AUTHORIZATION=get_basic_auth_header(self.username, self.password) 417 | ) 418 | response = self.client.post(url, {}, format='json') 419 | reload(views) 420 | self.assertEqual(response.status_code, 403) 421 | self.assertEqual(response.data, 422 | {"error": "Maximum amount of tokens allowed per user exceeded."}) 423 | 424 | def test_does_not_exceed_on_expired_keys(self): 425 | with override_settings(REST_KNOX=token_user_limit_knox): 426 | reload(views) 427 | for _ in range(9): 428 | AuthToken.objects.create(user=self.user) 429 | AuthToken.objects.create(user=self.user, expiry=timedelta(seconds=-1)) 430 | # now 10 keys, but only 9 valid so request should succeed. 431 | url = reverse('knox_login') 432 | self.client.credentials( 433 | HTTP_AUTHORIZATION=get_basic_auth_header(self.username, self.password) 434 | ) 435 | response = self.client.post(url, {}, format='json') 436 | failed_response = self.client.post(url, {}, format='json') 437 | reload(views) 438 | self.assertEqual(response.status_code, 200) 439 | self.assertIn('token', response.data) 440 | self.assertEqual(failed_response.status_code, 403) 441 | self.assertEqual(failed_response.data, 442 | {"error": "Maximum amount of tokens allowed per user exceeded."}) 443 | 444 | def test_invalid_prefix_return_401(self): 445 | with override_settings(REST_KNOX=auth_header_prefix_knox): 446 | reload(auth) 447 | instance, token = AuthToken.objects.create(user=self.user) 448 | self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) 449 | failed_response = self.client.get(root_url) 450 | self.client.credentials( 451 | HTTP_AUTHORIZATION=( 452 | 'Baerer %s' % token 453 | ) 454 | ) 455 | response = self.client.get(root_url) 456 | reload(auth) 457 | self.assertEqual(failed_response.status_code, 401) 458 | self.assertEqual(response.status_code, 200) 459 | 460 | def test_expiry_present_also_when_none(self): 461 | with override_settings(REST_KNOX=token_no_expiration_knox): 462 | reload(views) 463 | self.assertEqual(AuthToken.objects.count(), 0) 464 | url = reverse('knox_login') 465 | self.client.credentials( 466 | HTTP_AUTHORIZATION=get_basic_auth_header(self.username, self.password) 467 | ) 468 | response = self.client.post( 469 | url, 470 | {}, 471 | format='json' 472 | ) 473 | self.assertEqual(token_no_expiration_knox["TOKEN_TTL"], None) 474 | self.assertEqual(response.status_code, 200) 475 | self.assertIn('token', response.data) 476 | self.assertIn('expiry', response.data) 477 | self.assertEqual( 478 | response.data['expiry'], 479 | None 480 | ) 481 | reload(views) 482 | 483 | def test_expiry_is_present(self): 484 | self.assertEqual(AuthToken.objects.count(), 0) 485 | url = reverse('knox_login') 486 | self.client.credentials( 487 | HTTP_AUTHORIZATION=get_basic_auth_header(self.username, self.password) 488 | ) 489 | response = self.client.post( 490 | url, 491 | {}, 492 | format='json' 493 | ) 494 | self.assertEqual(response.status_code, 200) 495 | self.assertIn('token', response.data) 496 | self.assertIn('expiry', response.data) 497 | self.assertEqual( 498 | response.data['expiry'], 499 | DateTimeField().to_representation(AuthToken.objects.first().expiry) 500 | ) 501 | 502 | def test_login_returns_serialized_token_with_prefix_when_prefix_set(self): 503 | with override_settings(REST_KNOX=token_prefix_knox): 504 | reload(views) 505 | reload(crypto) 506 | self.assertEqual(AuthToken.objects.count(), 0) 507 | url = reverse('knox_login') 508 | self.client.credentials( 509 | HTTP_AUTHORIZATION=get_basic_auth_header(self.username, self.password) 510 | ) 511 | response = self.client.post( 512 | url, 513 | {}, 514 | format='json' 515 | ) 516 | self.assertEqual(response.status_code, 200) 517 | self.assertTrue(response.data['token'].startswith(token_prefix)) 518 | reload(views) 519 | reload(crypto) 520 | 521 | def test_token_with_prefix_returns_200(self): 522 | with override_settings(REST_KNOX=token_prefix_knox): 523 | reload(views) 524 | self.assertEqual(AuthToken.objects.count(), 0) 525 | url = reverse('knox_login') 526 | self.client.credentials( 527 | HTTP_AUTHORIZATION=get_basic_auth_header(self.username, self.password) 528 | ) 529 | response = self.client.post( 530 | url, 531 | {}, 532 | format='json' 533 | ) 534 | self.assertEqual(response.status_code, 200) 535 | self.assertTrue(response.data['token'].startswith(token_prefix)) 536 | self.client.credentials( 537 | HTTP_AUTHORIZATION=('Token %s' % response.data['token']) 538 | ) 539 | response = self.client.get(root_url, {}, format='json') 540 | self.assertEqual(response.status_code, 200) 541 | reload(views) 542 | 543 | def test_prefix_set_longer_than_max_length_raises_valueerror(self): 544 | with self.assertRaises(ValueError): 545 | with override_settings(REST_KNOX=token_prefix_too_long_knox): 546 | pass 547 | 548 | def test_tokens_created_before_prefix_still_work(self): 549 | self.client.credentials( 550 | HTTP_AUTHORIZATION=get_basic_auth_header(self.username, self.password) 551 | ) 552 | url = reverse('knox_login') 553 | response = self.client.post( 554 | url, 555 | {}, 556 | format='json' 557 | ) 558 | self.assertFalse(response.data['token'].startswith(token_prefix)) 559 | with override_settings(REST_KNOX=token_prefix_knox): 560 | reload(views) 561 | self.client.credentials( 562 | HTTP_AUTHORIZATION=('Token %s' % response.data['token']) 563 | ) 564 | response = self.client.get(root_url, {}, format='json') 565 | self.assertEqual(response.status_code, 200) 566 | reload(views) 567 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{38,39,310,311,312}-django42, 4 | py{310,311,312}-django50, 5 | 6 | [testenv] 7 | commands = 8 | python manage.py migrate 9 | coverage run manage.py test 10 | coverage report 11 | setenv = 12 | DJANGO_SETTINGS_MODULE = knox_project.settings 13 | PIP_INDEX_URL = https://pypi.python.org/simple/ 14 | deps = 15 | django42: Django>=4.2,<4.3 16 | django50: Django>=5.0,<5.1 17 | markdown>=3.0 18 | djangorestframework 19 | freezegun 20 | mkdocs 21 | pytest-django 22 | setuptools 23 | twine 24 | wheel 25 | coverage 26 | 27 | [gh-actions] 28 | python = 29 | 3.8: py38 30 | 3.9: py39 31 | 3.10: py310 32 | 3.11: py311 33 | 3.12: py312 34 | --------------------------------------------------------------------------------