diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 00000000..cfce0f82 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,11 @@ +coverage: + status: + project: + default: false + tests: + paths: tests + informational: true + knox: + paths: knox + informational: true + patch: off \ No newline at end of file diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..9d0f838e --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[run] +branch = True +source = knox +omit = + */migrations/* diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml new file mode 100644 index 00000000..7f3937b6 --- /dev/null +++ b/.github/workflows/gh-pages.yml @@ -0,0 +1,34 @@ +name: Publish Docs to GitHub Pages + +permissions: + contents: write + +on: + push: + branches: + - develop + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.x + + - name: Install dependencies + run: pip install mkdocs-material + + - name: Build docs + run: mkdocs build + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + personal_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./site \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 379d39cc..de70e1e1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,23 +9,23 @@ jobs: fail-fast: false max-parallel: 5 matrix: - python-version: ['3.6', '3.7', '3.8', '3.9', '3.10'] + python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Get pip cache dir id: pip-cache run: | - echo "::set-output name=dir::$(pip cache dir)" + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ${{ steps.pip-cache.outputs.dir }} key: @@ -36,8 +36,14 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install --upgrade tox tox-gh-actions + python -m pip install --upgrade tox tox-gh-actions coverage - name: Tox tests run: | tox -v + + - name: Generate coverage XML report + run: coverage xml + + - name: Codecov + uses: codecov/codecov-action@v3 diff --git a/.gitignore b/.gitignore index 6e5bf840..7791a82c 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ docs/_build/ target/ db.sqlite3 site/ + +# PyCharm Project +.idea diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..eac9b90d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +repos: + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + - repo: https://github.com/PyCQA/flake8 + rev: 7.0.0 + hooks: + - id: flake8 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 96a31be7..c7db5936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,29 @@ +## 5.0.0 +- Tokens created prior to this release will no longer work +- Fix migration reverse flow, enable migrate 0 +- Various documentation fixes and improvements +- Drop `cryptography` in favor of hashlib +- Make custom AuthModel work +- Token prefix can be set in the setttings +- Drop support for Django 4.0 +- Add support for Dango 4.2, 5.0 and Python 3.11 and 3.12 +- Cleanup legacy Python 2.0 code +- Fix isort, flake8 usage for Python 3.10 in the test suite +- Update Github actions version +- Upgrade markdown dependency +- Get rid of the `six` library +- Add custom login / logout response support +- Join the jazzband organization +- Add pre-commit hooks +- Add tracking of tests code coverage +- Fix migrations when used in condition with a custom DB +- Improve typing +- Use `self.authenticate_header()` in `authenticate()` method to get auth header prefix + ## 4.2.0 -- compatibility with Python up to 3.10 and Django up to 3.2 +- compatibility with Python up to 3.10 and Django up to 4.0 - integration with github CI instead of travis -- Migration: "salt" field of model "AuthToken" is removed +- Migration: "salt" field of model "AuthToken" is removed, WARNING: invalidates old tokens! ## 4.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..10d79191 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,3 @@ +[![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) + +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). \ No newline at end of file diff --git a/README.md b/README.md index 203db1f1..001fccc2 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,18 @@ django-rest-knox ================ -[![image](https://github.com/James1345/django-rest-knox/workflows/Test/badge.svg?branch=develop)](https://github.com/James1345/django-rest-knox/actions) +[![Jazzband](https://jazzband.co/static/img/badge.svg)](https://jazzband.co/) +[![image](https://github.com/jazzband/django-rest-knox/workflows/Test/badge.svg?branch=develop)](https://github.com/jazzband/django-rest-knox/actions) -Authentication Module for django rest auth +Authentication module for Django rest auth. -Knox provides easy to use authentication for [Django REST +Knox provides easy-to-use authentication for [Django REST Framework](https://www.django-rest-framework.org/) The aim is to allow -for common patterns in applications that are REST based, with little +for common patterns in applications that are REST-based, with little extra effort; and to ensure that connections remain secure. -Knox authentication is token based, similar to the `TokenAuthentication` -built in to DRF. However, it overcomes some problems present in the +Knox authentication is token-based, similar to the `TokenAuthentication` +built into DRF. However, it overcomes some problems present in the default implementation: - DRF tokens are limited to one per user. This does not facilitate @@ -23,13 +24,14 @@ default implementation: client to have its own token which is deleted on the server side when the client logs out. - Knox also provides an option for a logged in client to remove *all* + Knox also provides an option for a logged-in client to remove *all* tokens that the server has - forcing all clients to re-authenticate. - DRF tokens are stored unencrypted in the database. This would allow - an attacker unrestricted access to an account with a token if the + an attacker unrestricted access to an account with a token if the database were compromised. + Knox tokens are only stored in a secure hash form (like a password). Even if the database were somehow stolen, an attacker would not be able to log in with the stolen credentials. @@ -39,12 +41,11 @@ default implementation: the app settings (default is 10 hours.) More information can be found in the -[Documentation](https://james1345.github.io/django-rest-knox/) +[Documentation](https://jazzband.github.io/django-rest-knox/) # Run the tests locally -If you need to debug a test locally and if you have [docker](https://www.docker.com/) installed: - +If you need to debug a test locally and if you have [docker](https://www.docker.com/) installed, simply run the ``./docker-run-tests.sh`` script and it will run the test suite in every Python / Django versions. @@ -55,7 +56,7 @@ Python / Django versions a bit more tricky. Our documentation is generated by [Mkdocs](https://www.mkdocs.org). -You can refer to their documentation on how to install it locally. +You can refer to their [documentation](https://www.mkdocs.org/user-guide/installation/) on how to install it locally. Another option is to use `mkdocs.sh` in this repository. It will run mkdocs in a [docker](https://www.docker.com/) container. diff --git a/docs/auth.md b/docs/auth.md index 63828ed9..8e81c01f 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -49,18 +49,17 @@ If it is your only default authentication class, remember to overwrite knox's Lo 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: +**views.py:** ```python - -views.py: - from knox.views import LoginView as KnoxLoginView from rest_framework.authentication import BasicAuthentication class LoginView(KnoxLoginView): authentication_classes = [BasicAuthentication] +``` -urls.py: - +**urls.py:** +```python from knox import views as knox_views from yourapp.api.views import LoginView @@ -75,10 +74,8 @@ You can use any number of authentication classes if you want to be able to authe If you decide to use Token Authentication as your only authentication class, you can overwrite knox's login view as such: +**views.py:** ```python - -views.py: - from django.contrib.auth import login from rest_framework import permissions @@ -94,9 +91,10 @@ class LoginView(KnoxLoginView): user = serializer.validated_data['user'] login(request, user) return super(LoginView, self).post(request, format=None) +``` -urls.py: - +**urls.py:** +```python from knox import views as knox_views from yourapp.api.views import LoginView diff --git a/docs/index.md b/docs/index.md index 1619797b..4a674fed 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,10 +1,10 @@ # Django-Rest-Knox -Knox provides easy to use authentication for [Django REST Framework](https://www.django-rest-framework.org/) +Knox provides easy-to-use authentication for [Django REST Framework](https://www.django-rest-framework.org/) The aim is to allow for common patterns in applications that are REST based, with little extra effort; and to ensure that connections remain secure. Knox authentication is token based, similar to the `TokenAuthentication` built -in to DRF. However, it overcomes some problems present in the default implementation: +into DRF. However, it overcomes some problems present in the default implementation: - DRF tokens are limited to one per user. This does not facilitate securely signing in from multiple devices, as the token is shared. It also requires diff --git a/docs/installation.md b/docs/installation.md index e9a9d83a..afaeb559 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -2,27 +2,8 @@ ## Requirements -Knox depends on `cryptography` to provide bindings to `OpenSSL` for token generation. -This requires the OpenSSL build libraries to be available. - -### Windows -Cryptography is a statically linked build, no extra steps are needed. - -### Linux -`cryptography` should build very easily on Linux provided you have a C compiler, -headers for Python (if you’re not using `pypy`), and headers for the OpenSSL and -`libffi` libraries available on your system. - -Debian and Ubuntu: -```bash -sudo apt-get install build-essential libssl-dev libffi-dev python3-dev python-dev -``` - -Fedora and RHEL-derivatives: -```bash -sudo yum install gcc libffi-devel python-devel openssl-devel -``` -For other systems or problems, see the [cryptography installation docs](https://cryptography.io/en/latest/installation/) +Knox depends on pythons internal library `hashlib` to provide bindings to `OpenSSL` or uses +an internal implementation of hashing algorithms for token generation. ## Installing Knox Knox should be installed with pip @@ -59,7 +40,7 @@ REST_FRAMEWORK = { - 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. -- Apply the migrations for the models +- Apply the migrations for the models. ```bash python manage.py migrate diff --git a/docs/settings.md b/docs/settings.md index e44e322b..0ba2317d 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -10,18 +10,27 @@ Example `settings.py` # These are the default values if none are set from datetime import timedelta from rest_framework.settings import api_settings + +KNOX_TOKEN_MODEL = 'knox.AuthToken' + REST_KNOX = { - 'SECURE_HASH_ALGORITHM': 'cryptography.hazmat.primitives.hashes.SHA512', + 'SECURE_HASH_ALGORITHM': 'hashlib.sha512', 'AUTH_TOKEN_CHARACTER_LENGTH': 64, 'TOKEN_TTL': timedelta(hours=10), 'USER_SERIALIZER': 'knox.serializers.UserSerializer', 'TOKEN_LIMIT_PER_USER': None, 'AUTO_REFRESH': False, + 'MIN_REFRESH_INTERVAL': 60, + 'AUTH_HEADER_PREFIX': 'Token', 'EXPIRY_DATETIME_FORMAT': api_settings.DATETIME_FORMAT, + 'TOKEN_MODEL': 'knox.AuthToken', } #...snip... ``` +## KNOX_TOKEN_MODEL +This is the variable used in the swappable dependency of the `AuthToken` model + ## SECURE_HASH_ALGORITHM This is a reference to the class used to provide the hashing algorithm for token storage. @@ -30,14 +39,13 @@ token storage. By default, Knox uses SHA-512 to hash tokens in the database. -`cryptography.hazmat.primitives.hashes.Whirlpool` is an acceptable alternative setting -for production use. +`hashlib.sha3_512` is an acceptable alternative setting for production use. ### Tests -SHA-512 and Whirlpool are secure, however, they are slow. This should not be a +SHA-512 and SHA3-512 are secure, however, they are slow. This should not be a problem for your users, but when testing it may be noticeable (as test cases tend to use many more requests much more quickly than real users). In testing scenarios -it is acceptable to use `MD5` hashing.(`cryptography.hazmat.primitives.hashes.MD5`) +it is acceptable to use `MD5` hashing (`hashlib.md5`). MD5 is **not secure** and must *never* be used in production sites. @@ -58,7 +66,8 @@ Warning: setting a 0 or negative timedelta will create tokens that instantly exp the system will not prevent you setting this. ## TOKEN_LIMIT_PER_USER -This allows you to control how many tokens can be issued per user. +This allows you to control how many valid tokens can be issued per user. +If the limit for valid tokens is reached, an error is returned at login. By default this option is disabled and set to `None` -- thus no limit. ## USER_SERIALIZER @@ -81,9 +90,18 @@ This is the expiry datetime format returned in the login view. The default is th [DATETIME_FORMAT][DATETIME_FORMAT] of Django REST framework. May be any of `None`, `iso-8601` or a Python [strftime format][strftime format] string. +## TOKEN_MODEL +This is the reference to the model used as `AuthToken`. We can define a custom `AuthToken` +model in our project that extends `knox.AbstractAuthToken` and add our business logic to it. +The default is `knox.AuthToken` + [DATETIME_FORMAT]: https://www.django-rest-framework.org/api-guide/settings/#date-and-time-formatting [strftime format]: https://docs.python.org/3/library/time.html#time.strftime +## TOKEN_PREFIX +This is the prefix for the generated token that is used in the Authorization header. The default is just an empty string. +It can be up to `CONSTANTS.MAXIMUM_TOKEN_PREFIX_LENGTH` long. + # Constants `knox.settings` Knox also provides some constants for information. These must not be changed in external code; they are used in the model definitions in knox and an error will @@ -97,3 +115,6 @@ print(CONSTANTS.DIGEST_LENGTH) #=> 128 ## DIGEST_LENGTH This is the length of the digest that will be stored in the database for each token. + +## MAXIMUM_TOKEN_PREFIX_LENGTH +This is the maximum length of the token prefix. diff --git a/docs/views.md b/docs/views.md index c8ed762b..285222da 100644 --- a/docs/views.md +++ b/docs/views.md @@ -21,6 +21,7 @@ helper methods: - `get_user_serializer_class(self)`, to change the class used for serializing the user - `get_expiry_datetime_format(self)`, to change the datetime format used for expiry - `format_expiry_datetime(self, expiry)`, to format the expiry `datetime` object at your convenience +- `create_token(self)`, to create the `AuthToken` instance at your convenience Finally, if none of these helper methods are sufficient, you can also override `get_post_response_data` to return a fully customized payload. @@ -66,12 +67,24 @@ It responds to Knox Token Authentication. On a successful request, the token used to authenticate is deleted from the system and can no longer be used to authenticate. +By default, this endpoint returns a HTTP 204 response on a successful request. To +customize this behavior, you can override the `get_post_response` method, for example +to include a body in the logout response and/or to modify the status code: + +```python +...snip... + def get_post_response(self, request): + return Response({"bye-bye": request.user.username}, status=200) +...snip... +``` + ## LogoutAllView This view accepts only a post request with an empty body. It responds to Knox Token Authentication. -On a successful request, the token used to authenticate, and *all other tokens* -registered to the same `User` account, are deleted from the -system and can no longer be used to authenticate. +On a successful request, a HTTP 204 is returned and the token used to authenticate, +and *all other tokens* registered to the same `User` account, are deleted from the +system and can no longer be used to authenticate. The success response can be modified +like the `LogoutView` by overriding the `get_post_response` method. **Note** It is not recommended to alter the Logout views. They are designed specifically for token management, and to respond to Knox authentication. diff --git a/knox/auth.py b/knox/auth.py index c013c4dd..858ee504 100644 --- a/knox/auth.py +++ b/knox/auth.py @@ -1,10 +1,5 @@ -try: - from hmac import compare_digest -except ImportError: - def compare_digest(a, b): - return a == b - import binascii +from hmac import compare_digest from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -14,7 +9,7 @@ def compare_digest(a, b): ) from knox.crypto import hash_token -from knox.models import AuthToken +from knox.models import get_token_model from knox.settings import CONSTANTS, knox_settings from knox.signals import token_expired @@ -31,11 +26,10 @@ class TokenAuthentication(BaseAuthentication): - `request.user` will be a django `User` instance - `request.auth` will be an `AuthToken` instance ''' - model = AuthToken def authenticate(self, request): auth = get_authorization_header(request).split() - prefix = knox_settings.AUTH_HEADER_PREFIX.encode() + prefix = self.authenticate_header(request).encode() if not auth: return None @@ -62,7 +56,7 @@ def authenticate_credentials(self, token): ''' msg = _('Invalid token.') token = token.decode("utf-8") - for auth_token in AuthToken.objects.filter( + for auth_token in get_token_model().objects.filter( token_key=token[:CONSTANTS.TOKEN_KEY_LENGTH]): if self._cleanup_token(auth_token): continue @@ -77,7 +71,7 @@ def authenticate_credentials(self, token): return self.validate_user(auth_token) raise exceptions.AuthenticationFailed(msg) - def renew_token(self, auth_token): + def renew_token(self, auth_token) -> None: current_expiry = auth_token.expiry new_expiry = timezone.now() + knox_settings.TOKEN_TTL auth_token.expiry = new_expiry @@ -95,7 +89,7 @@ def validate_user(self, auth_token): def authenticate_header(self, request): return knox_settings.AUTH_HEADER_PREFIX - def _cleanup_token(self, auth_token): + def _cleanup_token(self, auth_token) -> bool: for other_token in auth_token.user.auth_token_set.all(): if other_token.digest != auth_token.digest and other_token.expiry: if other_token.expiry < timezone.now(): diff --git a/knox/crypto.py b/knox/crypto.py index dba4f754..02e70ffe 100644 --- a/knox/crypto.py +++ b/knox/crypto.py @@ -1,28 +1,31 @@ import binascii from os import urandom as generate_bytes -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import hashes - from knox.settings import knox_settings -sha = knox_settings.SECURE_HASH_ALGORITHM +hash_func = knox_settings.SECURE_HASH_ALGORITHM -def create_token_string(): +def create_token_string() -> str: return binascii.hexlify( generate_bytes(int(knox_settings.AUTH_TOKEN_CHARACTER_LENGTH / 2)) ).decode() -def hash_token(token): - ''' +def make_hex_compatible(token: str) -> bytes: + """ + We need to make sure that the token, that is send is hex-compatible. + When a token prefix is used, we cannot guarantee that. + """ + return binascii.unhexlify(binascii.hexlify(bytes(token, 'utf-8'))) + + +def hash_token(token: str) -> str: + """ Calculates the hash of a token. - input is unhexlified - - token must contain an even number of hex digits or a binascii.Error - exception will be raised - ''' - digest = hashes.Hash(sha(), backend=default_backend()) - digest.update(binascii.unhexlify(token)) - return binascii.hexlify(digest.finalize()).decode() + Token must contain an even number of hex digits or + a binascii.Error exception will be raised. + """ + digest = hash_func() + digest.update(make_hex_compatible(token)) + return digest.hexdigest() diff --git a/knox/migrations/0001_initial.py b/knox/migrations/0001_initial.py index e2b157ec..822176ea 100644 --- a/knox/migrations/0001_initial.py +++ b/knox/migrations/0001_initial.py @@ -9,6 +9,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), + migrations.swappable_dependency(settings.KNOX_TOKEN_MODEL), ] operations = [ diff --git a/knox/migrations/0006_auto_20160818_0932.py b/knox/migrations/0006_auto_20160818_0932.py index b8540905..ea3e5303 100644 --- a/knox/migrations/0006_auto_20160818_0932.py +++ b/knox/migrations/0006_auto_20160818_0932.py @@ -7,7 +7,7 @@ def cleanup_tokens(apps, schema_editor): AuthToken = apps.get_model('knox', 'AuthToken') - AuthToken.objects.filter(token_key__isnull=True).delete() + AuthToken.objects.using(schema_editor.connection.alias).filter(token_key__isnull=True).delete() class Migration(migrations.Migration): @@ -17,7 +17,7 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RunPython(cleanup_tokens), + migrations.RunPython(cleanup_tokens, reverse_code=migrations.RunPython.noop), migrations.AlterField( model_name='authtoken', name='token_key', diff --git a/knox/migrations/0009_extend_authtoken_field.py b/knox/migrations/0009_extend_authtoken_field.py new file mode 100644 index 00000000..18a33836 --- /dev/null +++ b/knox/migrations/0009_extend_authtoken_field.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1rc1 on 2022-07-20 17:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("knox", "0008_remove_authtoken_salt"), + ] + + operations = [ + migrations.AlterField( + model_name="authtoken", + name="token_key", + field=models.CharField(db_index=True, max_length=25), + ), + ] diff --git a/knox/models.py b/knox/models.py index 4dbee1ae..0ee00d98 100644 --- a/knox/models.py +++ b/knox/models.py @@ -1,39 +1,76 @@ +from django.apps import apps from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from django.db import models from django.utils import timezone from knox import crypto from knox.settings import CONSTANTS, knox_settings +sha = knox_settings.SECURE_HASH_ALGORITHM + User = settings.AUTH_USER_MODEL class AuthTokenManager(models.Manager): - def create(self, user, expiry=knox_settings.TOKEN_TTL): - token = crypto.create_token_string() + def create( + self, + user, + expiry=knox_settings.TOKEN_TTL, + prefix=knox_settings.TOKEN_PREFIX, + **kwargs + ): + token = prefix + crypto.create_token_string() digest = crypto.hash_token(token) - if expiry is not None: expiry = timezone.now() + expiry - instance = super(AuthTokenManager, self).create( token_key=token[:CONSTANTS.TOKEN_KEY_LENGTH], digest=digest, - user=user, expiry=expiry) + user=user, expiry=expiry, **kwargs) return instance, token -class AuthToken(models.Model): +class AbstractAuthToken(models.Model): objects = AuthTokenManager() digest = models.CharField( max_length=CONSTANTS.DIGEST_LENGTH, primary_key=True) token_key = models.CharField( - max_length=CONSTANTS.TOKEN_KEY_LENGTH, db_index=True) + max_length=CONSTANTS.MAXIMUM_TOKEN_PREFIX_LENGTH + + CONSTANTS.TOKEN_KEY_LENGTH, + db_index=True + ) user = models.ForeignKey(User, null=False, blank=False, related_name='auth_token_set', on_delete=models.CASCADE) created = models.DateTimeField(auto_now_add=True) expiry = models.DateTimeField(null=True, blank=True) - def __str__(self): - return '%s : %s' % (self.digest, self.user) + class Meta: + abstract = True + + def __str__(self) -> str: + return f'{self.digest} : {self.user}' + + +class AuthToken(AbstractAuthToken): + class Meta: + swappable = 'KNOX_TOKEN_MODEL' + + +def get_token_model(): + """ + Return the AuthToken model that is active in this project. + """ + + try: + return apps.get_model(knox_settings.TOKEN_MODEL, require_ready=False) + except ValueError: + raise ImproperlyConfigured( + "TOKEN_MODEL must be of the form 'app_label.model_name'" + ) + except LookupError: + raise ImproperlyConfigured( + "TOKEN_MODEL refers to model '%s' that has not been installed" + % knox_settings.TOKEN_MODEL + ) diff --git a/knox/settings.py b/knox/settings.py index 5bb14483..a2c3d9c8 100644 --- a/knox/settings.py +++ b/knox/settings.py @@ -1,13 +1,13 @@ from datetime import timedelta from django.conf import settings -from django.test.signals import setting_changed +from django.core.signals import setting_changed from rest_framework.settings import APISettings, api_settings USER_SETTINGS = getattr(settings, 'REST_KNOX', None) DEFAULTS = { - 'SECURE_HASH_ALGORITHM': 'cryptography.hazmat.primitives.hashes.SHA512', + 'SECURE_HASH_ALGORITHM': 'hashlib.sha512', 'AUTH_TOKEN_CHARACTER_LENGTH': 64, 'TOKEN_TTL': timedelta(hours=10), 'USER_SERIALIZER': None, @@ -16,6 +16,8 @@ 'MIN_REFRESH_INTERVAL': 60, 'AUTH_HEADER_PREFIX': 'Token', 'EXPIRY_DATETIME_FORMAT': api_settings.DATETIME_FORMAT, + 'TOKEN_MODEL': getattr(settings, 'KNOX_TOKEN_MODEL', 'knox.AuthToken'), + 'TOKEN_PREFIX': '', } IMPORT_STRINGS = { @@ -31,6 +33,8 @@ def reload_api_settings(*args, **kwargs): setting, value = kwargs['setting'], kwargs['value'] if setting == 'REST_KNOX': knox_settings = APISettings(value, DEFAULTS, IMPORT_STRINGS) + if len(knox_settings.TOKEN_PREFIX) > CONSTANTS.MAXIMUM_TOKEN_PREFIX_LENGTH: + raise ValueError("Illegal TOKEN_PREFIX length") setting_changed.connect(reload_api_settings) @@ -40,8 +44,9 @@ class CONSTANTS: ''' Constants cannot be changed at runtime ''' - TOKEN_KEY_LENGTH = 8 + TOKEN_KEY_LENGTH = 15 DIGEST_LENGTH = 128 + MAXIMUM_TOKEN_PREFIX_LENGTH = 10 def __setattr__(self, *args, **kwargs): raise Exception(''' @@ -50,4 +55,4 @@ def __setattr__(self, *args, **kwargs): ''') -CONSTANTS = CONSTANTS() +CONSTANTS = CONSTANTS() # type: ignore diff --git a/knox/views.py b/knox/views.py index 7975bbeb..97cbe42c 100644 --- a/knox/views.py +++ b/knox/views.py @@ -8,7 +8,7 @@ from rest_framework.views import APIView from knox.auth import TokenAuthentication -from knox.models import AuthToken +from knox.models import get_token_model from knox.settings import knox_settings @@ -22,6 +22,9 @@ def get_context(self): def get_token_ttl(self): return knox_settings.TOKEN_TTL + def get_token_prefix(self): + return knox_settings.TOKEN_PREFIX + def get_token_limit_per_user(self): return knox_settings.TOKEN_LIMIT_PER_USER @@ -35,6 +38,12 @@ def format_expiry_datetime(self, expiry): datetime_format = self.get_expiry_datetime_format() return DateTimeField(format=datetime_format).to_representation(expiry) + def create_token(self): + token_prefix = self.get_token_prefix() + return get_token_model().objects.create( + user=self.request.user, expiry=self.get_token_ttl(), prefix=token_prefix + ) + def get_post_response_data(self, request, token, instance): UserSerializer = self.get_user_serializer_class() @@ -49,6 +58,10 @@ def get_post_response_data(self, request, token, instance): ).data return data + def get_post_response(self, request, token, instance): + data = self.get_post_response_data(request, token, instance) + return Response(data) + def post(self, request, format=None): token_limit_per_user = self.get_token_limit_per_user() if token_limit_per_user is not None: @@ -59,23 +72,24 @@ def post(self, request, format=None): {"error": "Maximum amount of tokens allowed per user exceeded."}, status=status.HTTP_403_FORBIDDEN ) - token_ttl = self.get_token_ttl() - instance, token = AuthToken.objects.create(request.user, token_ttl) + instance, token = self.create_token() user_logged_in.send(sender=request.user.__class__, request=request, user=request.user) - data = self.get_post_response_data(request, token, instance) - return Response(data) + return self.get_post_response(request, token, instance) class LogoutView(APIView): authentication_classes = (TokenAuthentication,) permission_classes = (IsAuthenticated,) + def get_post_response(self, request): + return Response(None, status=status.HTTP_204_NO_CONTENT) + def post(self, request, format=None): request._auth.delete() user_logged_out.send(sender=request.user.__class__, request=request, user=request.user) - return Response(None, status=status.HTTP_204_NO_CONTENT) + return self.get_post_response(request) class LogoutAllView(APIView): @@ -86,8 +100,11 @@ class LogoutAllView(APIView): authentication_classes = (TokenAuthentication,) permission_classes = (IsAuthenticated,) + def get_post_response(self, request): + return Response(None, status=status.HTTP_204_NO_CONTENT) + def post(self, request, format=None): request.user.auth_token_set.all().delete() user_logged_out.send(sender=request.user.__class__, request=request, user=request.user) - return Response(None, status=status.HTTP_204_NO_CONTENT) + return self.get_post_response(request) diff --git a/knox_project/settings.py b/knox_project/settings.py index 85dcc48d..d5b6c40c 100644 --- a/knox_project/settings.py +++ b/knox_project/settings.py @@ -49,7 +49,9 @@ LANGUAGE_CODE = 'en-us' TIME_ZONE = 'UTC' USE_I18N = True -USE_L10N = True +USE_L10N = True # Deprecated since django 4.0. USE_TZ = True STATIC_URL = '/static/' + +KNOX_TOKEN_MODEL = 'knox.AuthToken' diff --git a/mkdocs.sh b/mkdocs.sh index ac64849e..8365e1ae 100755 --- a/mkdocs.sh +++ b/mkdocs.sh @@ -9,4 +9,4 @@ docker run --rm -it \ -w $MOUNT_FOLDER \ -p $MKDOCS_DEV_PORT:$MKDOCS_DEV_PORT \ -e MKDOCS_DEV_ADDR="$MKDOCS_DEV_ADDR:$MKDOCS_DEV_PORT" \ - squidfunk/mkdocs-material:3.2.0 $* + squidfunk/mkdocs-material:latest $* diff --git a/mkdocs.yml b/mkdocs.yml index 571ed993..2ecec2a3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: Django-Rest-Knox -repo_url: https://github.com/James1345/django-rest-knox +repo_url: https://github.com/jazzband/django-rest-knox theme: readthedocs nav: - Home: 'index.md' diff --git a/setup.py b/setup.py index 30d63bdb..6ff24321 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ long_description_content_type='text/markdown', # The project's main homepage. - url='https://github.com/James1345/django-rest-knox', + url='https://github.com/jazzband/django-rest-knox', # Author details author='James McMahon', @@ -51,6 +51,8 @@ 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', ], # What does your project relate to? @@ -69,7 +71,6 @@ install_requires=[ 'django>=3.2', 'djangorestframework', - 'cryptography', ], # List additional groups of dependencies here (e.g. development diff --git a/tests/tests.py b/tests/tests.py index 914ce204..9494db0b 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -1,5 +1,6 @@ import base64 from datetime import datetime, timedelta +from importlib import reload from django.contrib.auth import get_user_model from django.test import override_settings @@ -8,9 +9,8 @@ from rest_framework.exceptions import AuthenticationFailed from rest_framework.serializers import DateTimeField from rest_framework.test import APIRequestFactory, APITestCase as TestCase -from six.moves import reload_module -from knox import auth, views +from knox import auth, crypto, views from knox.auth import TokenAuthentication from knox.models import AuthToken from knox.serializers import UserSerializer @@ -23,7 +23,7 @@ def get_basic_auth_header(username, password): return 'Basic %s' % base64.b64encode( - ('%s:%s' % (username, password)).encode('ascii')).decode() + (f'{username}:{password}').encode('ascii')).decode() auto_refresh_knox = knox_settings.defaults.copy() @@ -45,6 +45,14 @@ def get_basic_auth_header(username, password): expiry_datetime_format_knox = knox_settings.defaults.copy() expiry_datetime_format_knox["EXPIRY_DATETIME_FORMAT"] = EXPIRY_DATETIME_FORMAT +token_prefix = "TEST_" +token_prefix_knox = knox_settings.defaults.copy() +token_prefix_knox["TOKEN_PREFIX"] = token_prefix + +token_prefix_too_long = "a" * CONSTANTS.MAXIMUM_TOKEN_PREFIX_LENGTH + "a" +token_prefix_too_long_knox = knox_settings.defaults.copy() +token_prefix_too_long_knox["TOKEN_PREFIX"] = token_prefix_too_long + class AuthTestCase(TestCase): @@ -86,7 +94,7 @@ def test_login_returns_serialized_token(self): def test_login_returns_serialized_token_and_username_field(self): with override_settings(REST_KNOX=user_serializer_knox): - reload_module(views) + reload(views) self.assertEqual(AuthToken.objects.count(), 0) url = reverse('knox_login') self.client.credentials( @@ -94,7 +102,7 @@ def test_login_returns_serialized_token_and_username_field(self): ) response = self.client.post(url, {}, format='json') self.assertEqual(user_serializer_knox["USER_SERIALIZER"], UserSerializer) - reload_module(views) + (views) self.assertEqual(response.status_code, 200) self.assertIn('token', response.data) username_field = self.user.USERNAME_FIELD @@ -104,7 +112,7 @@ def test_login_returns_serialized_token_and_username_field(self): def test_login_returns_configured_expiry_datetime_format(self): with override_settings(REST_KNOX=expiry_datetime_format_knox): - reload_module(views) + reload(views) self.assertEqual(AuthToken.objects.count(), 0) url = reverse('knox_login') self.client.credentials( @@ -115,7 +123,7 @@ def test_login_returns_configured_expiry_datetime_format(self): expiry_datetime_format_knox["EXPIRY_DATETIME_FORMAT"], EXPIRY_DATETIME_FORMAT ) - reload_module(views) + reload(views) self.assertEqual(response.status_code, 200) self.assertIn('token', response.data) self.assertNotIn('user', response.data) @@ -190,7 +198,7 @@ def test_update_token_key(self): instance, token = AuthToken.objects.create(self.user) rf = APIRequestFactory() request = rf.get('/') - request.META = {'HTTP_AUTHORIZATION': 'Token {}'.format(token)} + request.META = {'HTTP_AUTHORIZATION': f'Token {token}'} (self.user, auth_token) = TokenAuthentication().authenticate(request) self.assertEqual( token[:CONSTANTS.TOKEN_KEY_LENGTH], @@ -250,10 +258,10 @@ def test_token_expiry_is_extended_with_auto_refresh_activated(self): self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) five_hours_later = original_time + timedelta(hours=5) with override_settings(REST_KNOX=auto_refresh_knox): - reload_module(auth) # necessary to reload settings in core code + reload(auth) # necessary to reload settings in core code with freeze_time(five_hours_later): response = self.client.get(root_url, {}, format='json') - reload_module(auth) + reload(auth) self.assertEqual(response.status_code, 200) # original expiry date was extended: @@ -302,10 +310,10 @@ def test_token_expiry_is_not_extended_within_MIN_REFRESH_INTERVAL(self): self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) in_min_interval = now + timedelta(seconds=knox_settings.MIN_REFRESH_INTERVAL - 10) with override_settings(REST_KNOX=auto_refresh_knox): - reload_module(auth) # necessary to reload settings in core code + reload(auth) # necessary to reload settings in core code with freeze_time(in_min_interval): response = self.client.get(root_url, {}, format='json') - reload_module(auth) # necessary to reload settings in core code + reload(auth) # necessary to reload settings in core code self.assertEqual(response.status_code, 200) self.assertEqual(original_expiry, AuthToken.objects.get().expiry) @@ -318,7 +326,10 @@ def handler(sender, username, **kwargs): token_expired.connect(handler) - instance, token = AuthToken.objects.create(user=self.user, expiry=timedelta(seconds=-1)) + instance, token = AuthToken.objects.create( + user=self.user, + expiry=timedelta(seconds=-1), + ) self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) self.client.post(root_url, {}, format='json') @@ -327,7 +338,7 @@ def handler(sender, username, **kwargs): def test_exceed_token_amount_per_user(self): with override_settings(REST_KNOX=token_user_limit_knox): - reload_module(views) + reload(views) for _ in range(10): AuthToken.objects.create(user=self.user) url = reverse('knox_login') @@ -335,7 +346,7 @@ def test_exceed_token_amount_per_user(self): HTTP_AUTHORIZATION=get_basic_auth_header(self.username, self.password) ) response = self.client.post(url, {}, format='json') - reload_module(views) + reload(views) self.assertEqual(response.status_code, 403) self.assertEqual(response.data, {"error": "Maximum amount of tokens allowed per user exceeded."}) @@ -343,7 +354,7 @@ def test_exceed_token_amount_per_user(self): def test_does_not_exceed_on_expired_keys(self): with override_settings(REST_KNOX=token_user_limit_knox): - reload_module(views) + reload(views) for _ in range(9): AuthToken.objects.create(user=self.user) AuthToken.objects.create(user=self.user, expiry=timedelta(seconds=-1)) @@ -354,7 +365,7 @@ def test_does_not_exceed_on_expired_keys(self): ) response = self.client.post(url, {}, format='json') failed_response = self.client.post(url, {}, format='json') - reload_module(views) + reload(views) self.assertEqual(response.status_code, 200) self.assertIn('token', response.data) self.assertEqual(failed_response.status_code, 403) @@ -364,7 +375,7 @@ def test_does_not_exceed_on_expired_keys(self): def test_invalid_prefix_return_401(self): with override_settings(REST_KNOX=auth_header_prefix_knox): - reload_module(auth) + reload(auth) instance, token = AuthToken.objects.create(user=self.user) self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) failed_response = self.client.get(root_url) @@ -374,13 +385,13 @@ def test_invalid_prefix_return_401(self): ) ) response = self.client.get(root_url) - reload_module(auth) + reload(auth) self.assertEqual(failed_response.status_code, 401) self.assertEqual(response.status_code, 200) def test_expiry_present_also_when_none(self): with override_settings(REST_KNOX=token_no_expiration_knox): - reload_module(views) + reload(views) self.assertEqual(AuthToken.objects.count(), 0) url = reverse('knox_login') self.client.credentials( @@ -399,7 +410,7 @@ def test_expiry_present_also_when_none(self): response.data['expiry'], None ) - reload_module(views) + reload(views) def test_expiry_is_present(self): self.assertEqual(AuthToken.objects.count(), 0) @@ -419,3 +430,69 @@ def test_expiry_is_present(self): response.data['expiry'], DateTimeField().to_representation(AuthToken.objects.first().expiry) ) + + def test_login_returns_serialized_token_with_prefix_when_prefix_set(self): + with override_settings(REST_KNOX=token_prefix_knox): + reload(views) + reload(crypto) + self.assertEqual(AuthToken.objects.count(), 0) + url = reverse('knox_login') + self.client.credentials( + HTTP_AUTHORIZATION=get_basic_auth_header(self.username, self.password) + ) + response = self.client.post( + url, + {}, + format='json' + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data['token'].startswith(token_prefix)) + reload(views) + reload(crypto) + + def test_token_with_prefix_returns_200(self): + with override_settings(REST_KNOX=token_prefix_knox): + reload(views) + self.assertEqual(AuthToken.objects.count(), 0) + url = reverse('knox_login') + self.client.credentials( + HTTP_AUTHORIZATION=get_basic_auth_header(self.username, self.password) + ) + response = self.client.post( + url, + {}, + format='json' + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data['token'].startswith(token_prefix)) + self.client.credentials( + HTTP_AUTHORIZATION=('Token %s' % response.data['token']) + ) + response = self.client.get(root_url, {}, format='json') + self.assertEqual(response.status_code, 200) + reload(views) + + def test_prefix_set_longer_than_max_length_raises_valueerror(self): + with self.assertRaises(ValueError): + with override_settings(REST_KNOX=token_prefix_too_long_knox): + pass + + def test_tokens_created_before_prefix_still_work(self): + self.client.credentials( + HTTP_AUTHORIZATION=get_basic_auth_header(self.username, self.password) + ) + url = reverse('knox_login') + response = self.client.post( + url, + {}, + format='json' + ) + self.assertFalse(response.data['token'].startswith(token_prefix)) + with override_settings(REST_KNOX=token_prefix_knox): + reload(views) + self.client.credentials( + HTTP_AUTHORIZATION=('Token %s' % response.data['token']) + ) + response = self.client.get(root_url, {}, format='json') + self.assertEqual(response.status_code, 200) + reload(views) diff --git a/tox.ini b/tox.ini index c6610d86..aaf77486 100644 --- a/tox.ini +++ b/tox.ini @@ -1,49 +1,37 @@ [tox] envlist = - isort, - flake8, py{36,37,38,39,310}-django32, - py{38,39,310}-django40, - -[testenv:flake8] -deps = flake8 -changedir = {toxinidir} -commands = flake8 knox - -[testenv:isort] -deps = isort -changedir = {toxinidir} -commands = isort --check-only --diff \ - knox \ - knox_project/views.py \ - setup.py \ - tests + py{38,39,310,311,312}-django42, + py{310,311,312}-django50, [testenv] commands = python manage.py migrate - python manage.py test + coverage run manage.py test + coverage report setenv = DJANGO_SETTINGS_MODULE = knox_project.settings PIP_INDEX_URL = https://pypi.python.org/simple/ deps = django32: Django>=3.2,<3.3 - django40: Django>=4.0,<4.1 - markdown<3.0 - isort>=5.0 + django42: Django>=4.2,<4.3 + django50: Django>=5.0,<5.1 + markdown>=3.0 djangorestframework freezegun mkdocs - cryptography pytest-django setuptools twine wheel + coverage [gh-actions] python = 3.6: py36 3.7: py37 3.8: py38 - 3.9: py39, isort, flake8 + 3.9: py39 3.10: py310 + 3.11: py311 + 3.12: py312