diff --git a/demo/app/migrations/0002_alter_userwithprofile_options_and_more.py b/demo/app/migrations/0002_alter_userwithprofile_options_and_more.py new file mode 100644 index 00000000..59ed004e --- /dev/null +++ b/demo/app/migrations/0002_alter_userwithprofile_options_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.1 on 2025-05-09 08:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='userwithprofile', + options={'verbose_name': 'user', 'verbose_name_plural': 'users'}, + ), + migrations.AlterField( + model_name='userwithprofile', + name='first_name', + field=models.CharField(blank=True, max_length=150, verbose_name='first name'), + ), + ] diff --git a/demo/app/urls.py b/demo/app/urls.py index 15ca78ad..f3a059d8 100644 --- a/demo/app/urls.py +++ b/demo/app/urls.py @@ -2,11 +2,11 @@ from __future__ import unicode_literals +from django.urls import re_path, include from rest_framework import routers from rest_framework_jwt import views from rest_framework_jwt.blacklist import views as blacklist_views -from rest_framework_jwt.compat import include, url from .views import test_view, superuser_test_view, blacklist_test_view @@ -14,12 +14,12 @@ router.register(r"blacklist", blacklist_views.BlacklistView, "blacklist") urlpatterns = [ - url(r"^auth/$", views.obtain_jwt_token, name="auth"), - url(r"^auth/verify/$", views.verify_jwt_token, name="auth-verify"), - url(r"^auth/refresh/$", views.refresh_jwt_token, name="auth-refresh"), - url(r"^impersonate/$", views.impersonate_jwt_token, name="impersonate"), - url(r"^test-view/$", test_view, name="test-view"), - url(r"^superuser-test-view/$", superuser_test_view, name="superuser-test-view"), - url(r"^blacklist-test-view/$", blacklist_test_view, name="blacklist-test-view"), - url(r"^", include(router.urls)), + re_path(r"^auth/$", views.obtain_jwt_token, name="auth"), + re_path(r"^auth/verify/$", views.verify_jwt_token, name="auth-verify"), + re_path(r"^auth/refresh/$", views.refresh_jwt_token, name="auth-refresh"), + re_path(r"^impersonate/$", views.impersonate_jwt_token, name="impersonate"), + re_path(r"^test-view/$", test_view, name="test-view"), + re_path(r"^superuser-test-view/$", superuser_test_view, name="superuser-test-view"), + re_path(r"^blacklist-test-view/$", blacklist_test_view, name="blacklist-test-view"), + re_path(r"^", include(router.urls)), ] diff --git a/demo/demo/urls.py b/demo/demo/urls.py index 023df508..c7b46df9 100644 --- a/demo/demo/urls.py +++ b/demo/demo/urls.py @@ -14,11 +14,10 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin - -from rest_framework_jwt.compat import include, url +from django.urls import re_path, include urlpatterns = [ - url(r"^admin/", admin.site.urls), - url(r"^jwt/", include("app.urls")), + re_path(r"^admin/", admin.site.urls), + re_path(r"^jwt/", include("app.urls")), ] diff --git a/docs/index.md b/docs/index.md index 6792c1a6..f4a356ca 100644 --- a/docs/index.md +++ b/docs/index.md @@ -548,7 +548,7 @@ Default is `False`. ## Extending/Overriding `JSONWebTokenAuthentication` -Right now `JSONWebTokenAuthentication` assumes that the JWT will come in the header, or a cookie if configured (see [JWT_AUTH_COOKIE](#JWT_AUTH_COOKIE)). The JWT spec does not require this (see: [Making a service Call](https://developer.atlassian.com/static/connect/docs/concepts/authentication.html)). For example, the JWT may come in the querystring. The ability to send the JWT in the querystring is needed in cases where the user cannot set the header (for example the src element in HTML). +Right now `JSONWebTokenAuthentication` assumes that the JWT will come in the header, or a cookie if configured (see [JWT_AUTH_COOKIE](#jwt_auth_cookie)). The JWT spec does not require this (see: [Making a service Call](https://developer.atlassian.com/static/connect/docs/concepts/authentication.html)). For example, the JWT may come in the querystring. The ability to send the JWT in the querystring is needed in cases where the user cannot set the header (for example the src element in HTML). To achieve this functionality, the user might write a custom `Authentication` class: diff --git a/mkdocs.yml b/mkdocs.yml index f934f516..c019cf07 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,3 +3,6 @@ site_description: JSON Web Token Authentication support for Django REST Framewor site_dir: html theme: readthedocs repo_url: https://github.com/Styria-Digital/django-rest-framework-jwt +markdown_extensions: + - toc: + permalink: true diff --git a/setup.cfg b/setup.cfg index 48025a4b..b4cc237b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,12 +19,9 @@ classifiers = License :: OSI Approved :: MIT License Operating System :: OS Independent Programming Language :: Python - Programming Language :: Python :: 2 - Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 - Programming Language :: Python :: 3.5 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 Topic :: Internet :: WWW/HTTP [paths] @@ -33,13 +30,13 @@ source = .tox/*/lib/python*/site-packages/rest_framework_jwt [options] -python_requires = >= 2.7, != 3.0.*, != 3.1.*, != 3.2.*, != 3.3.*, != 3.4.* +python_requires = >= 3.11 zip_safe = False include_package_data = True install_requires = - PyJWT[crypto]>=1.5.2,<3.0.0 - Django>=1.11 - djangorestframework>=3.7 + PyJWT[crypto]>=2.0.0,<3.0.0 + Django>=5.2 + djangorestframework>=3.16,<3.17 [options.extras_require] dev = @@ -50,13 +47,12 @@ lint = flake8 test = mock - pytest>=3.0 + pytest>=8.0 pytest-cov pytest-django pytest-runner - six docs = - mkdocs==0.13.2 + mkdocs==1.6.1 [bdist_wheel] universal = 1 diff --git a/src/rest_framework_jwt/authentication.py b/src/rest_framework_jwt/authentication.py index cdca5ee6..a82c764c 100644 --- a/src/rest_framework_jwt/authentication.py +++ b/src/rest_framework_jwt/authentication.py @@ -6,7 +6,8 @@ from django.apps import apps from django.contrib.auth import get_user_model -from django.utils.encoding import force_str +from django.utils.encoding import force_str, smart_str +from django.utils.translation import gettext_lazy as _ from rest_framework import exceptions from rest_framework.authentication import ( @@ -18,9 +19,6 @@ InvalidAuthorizationCredentials, MissingToken, ) -from rest_framework_jwt.compat import gettext_lazy as _ -from rest_framework_jwt.compat import smart_str -from rest_framework_jwt.compat import ExpiredSignature from rest_framework_jwt.settings import api_settings @@ -71,7 +69,7 @@ def authenticate(self, request): try: payload = self.jwt_decode_token(token) - except ExpiredSignature: + except jwt.ExpiredSignatureError: msg = _('Token has expired.') raise exceptions.AuthenticationFailed(msg) except jwt.DecodeError: diff --git a/src/rest_framework_jwt/blacklist/exceptions.py b/src/rest_framework_jwt/blacklist/exceptions.py index 65575f5c..9a7f08cc 100644 --- a/src/rest_framework_jwt/blacklist/exceptions.py +++ b/src/rest_framework_jwt/blacklist/exceptions.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- -from rest_framework.exceptions import AuthenticationFailed +from django.utils.translation import gettext_lazy as _ -from rest_framework_jwt.compat import gettext_lazy as _ +from rest_framework.exceptions import AuthenticationFailed class MissingToken(AuthenticationFailed): @@ -15,4 +15,3 @@ class InvalidAuthorizationCredentials(AuthenticationFailed): status_code = 401 msg = _('Invalid Authorization header.') default_code = 'invalid_authorization_credentials' - diff --git a/src/rest_framework_jwt/blacklist/permissions.py b/src/rest_framework_jwt/blacklist/permissions.py index f3e6f3a4..e7e69503 100644 --- a/src/rest_framework_jwt/blacklist/permissions.py +++ b/src/rest_framework_jwt/blacklist/permissions.py @@ -1,8 +1,10 @@ +from django.utils.translation import gettext_lazy as _ + from rest_framework.permissions import BasePermission from rest_framework_jwt.authentication import JSONWebTokenAuthentication from rest_framework_jwt.blacklist.models import BlacklistedToken -from rest_framework_jwt.compat import gettext_lazy as _, jwt_decode +from rest_framework_jwt.compat import jwt_decode class IsNotBlacklisted(BasePermission): message = _('You have been blacklisted.') diff --git a/src/rest_framework_jwt/compat.py b/src/rest_framework_jwt/compat.py index de89aabb..cf46931e 100644 --- a/src/rest_framework_jwt/compat.py +++ b/src/rest_framework_jwt/compat.py @@ -3,74 +3,32 @@ from __future__ import unicode_literals from datetime import datetime -import sys -from django import VERSION import jwt from .settings import api_settings -try: - from django.urls import include -except ImportError: - from django.conf.urls import include # noqa: F401 - - -try: - from django.conf.urls import url -except ImportError: - from django.urls import re_path as url - - -if sys.version_info[0] == 2: - # Use unicode-aware gettext on Python 2 - from django.utils.translation import ugettext_lazy as gettext_lazy -else: - from django.utils.translation import gettext_lazy as gettext_lazy - - -try: - from django.utils.encoding import smart_str -except ImportError: - from django.utils.encoding import smart_text as smart_str - - -def has_set_cookie_samesite(): - return (VERSION >= (2,1,0)) - - def set_cookie_with_token(response, name, token): params = { 'expires': datetime.utcnow() + api_settings.JWT_EXPIRATION_DELTA, 'domain': api_settings.JWT_AUTH_COOKIE_DOMAIN, 'path': api_settings.JWT_AUTH_COOKIE_PATH, 'secure': api_settings.JWT_AUTH_COOKIE_SECURE, - 'httponly': True + 'httponly': True, } - if has_set_cookie_samesite(): - params.update({'samesite': api_settings.JWT_AUTH_COOKIE_SAMESITE}) + params.update({'samesite': api_settings.JWT_AUTH_COOKIE_SAMESITE}) response.set_cookie(name, token, **params) -if jwt.__version__.startswith("2"): - jwt_version = 2 - ExpiredSignature = jwt.ExpiredSignatureError -else: - jwt_version = 1 - ExpiredSignature = jwt.ExpiredSignature - def jwt_decode(token, key, verify=None, **kwargs): if verify is not None: - if jwt_version == 1: - kwargs["verify"] = verify + if "options" not in kwargs: + kwargs["options"] = {"verify_signature": verify} else: - if "options" not in kwargs: - kwargs["options"] = {"verify_signature": verify} - else: - kwargs["options"]["verify_signature"] = verify - if jwt_version == 2 and "algorithms" not in kwargs: + kwargs["options"]["verify_signature"] = verify + if "algorithms" not in kwargs: kwargs["algorithms"] = ["HS256"] - return jwt.decode(token, key, **kwargs) \ No newline at end of file + return jwt.decode(token, key, **kwargs) diff --git a/src/rest_framework_jwt/serializers.py b/src/rest_framework_jwt/serializers.py index bade5d73..f7f0cf47 100644 --- a/src/rest_framework_jwt/serializers.py +++ b/src/rest_framework_jwt/serializers.py @@ -3,11 +3,11 @@ from __future__ import unicode_literals from django.contrib.auth import authenticate, get_user_model +from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from rest_framework_jwt.authentication import JSONWebTokenAuthentication -from rest_framework_jwt.compat import gettext_lazy as _ from rest_framework_jwt.settings import api_settings from rest_framework_jwt.utils import ( check_payload, diff --git a/src/rest_framework_jwt/utils.py b/src/rest_framework_jwt/utils.py index a59f5dce..03e395bb 100644 --- a/src/rest_framework_jwt/utils.py +++ b/src/rest_framework_jwt/utils.py @@ -10,11 +10,12 @@ from django.apps import apps from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from rest_framework.utils.encoders import JSONEncoder -from rest_framework_jwt.compat import gettext_lazy as _, jwt_version, jwt_decode, ExpiredSignature +from rest_framework_jwt.compat import jwt_decode from rest_framework_jwt.settings import api_settings @@ -127,15 +128,14 @@ def jwt_encode_payload(payload): key = key[0] enc = jwt.encode(payload, key, signing_algorithm, headers=headers, json_encoder=JSONEncoder) - if jwt_version == 1: - enc = enc.decode() + return enc def jwt_decode_token(token): """Decode JWT token claims.""" - if jwt_version == 2 and type(token) == bytes: + if type(token) == bytes: token = token.decode() options = { @@ -211,7 +211,7 @@ def check_payload(token): try: payload = JSONWebTokenAuthentication.jwt_decode_token(token) - except ExpiredSignature: + except jwt.ExpiredSignatureError: msg = _('Token has expired.') raise serializers.ValidationError(msg) except jwt.DecodeError: diff --git a/tests/models/test_blacklisted_token.py b/tests/models/test_blacklisted_token.py index e6a7242b..88fe4f53 100644 --- a/tests/models/test_blacklisted_token.py +++ b/tests/models/test_blacklisted_token.py @@ -2,7 +2,8 @@ from datetime import datetime from datetime import timedelta -from django.utils import timezone +from datetime import timezone +from django.utils import timezone as django_timezone import pytest @@ -21,7 +22,7 @@ def test_token_is_blocked_by_id(user, monkeypatch, id_setting): payload = JSONWebTokenAuthentication.jwt_create_payload(user) token = JSONWebTokenAuthentication.jwt_encode_payload(payload) - expiration = timezone.now() + timedelta(days=1) + expiration = django_timezone.now() + timedelta(days=1) BlacklistedToken( token_id=payload['jti'], expires_at=expiration, @@ -44,7 +45,7 @@ def test_refreshed_token_is_blocked_by_original_id(user, call_auth_refresh_endpo refreshed_token = refresh_response.json()['token'] payload = JSONWebTokenAuthentication.jwt_decode_token(refreshed_token) - expiration = timezone.now() + timedelta(days=1) + expiration = django_timezone.now() + timedelta(days=1) BlacklistedToken( token_id=original_payload['jti'], expires_at=expiration, @@ -62,7 +63,7 @@ def test_token_is_blocked_by_value(user, monkeypatch, id_setting): payload = JSONWebTokenAuthentication.jwt_create_payload(user) token = JSONWebTokenAuthentication.jwt_encode_payload(payload) - expiration = timezone.now() + timedelta(days=1) + expiration = django_timezone.now() + timedelta(days=1) BlacklistedToken( token=token, expires_at=expiration, @@ -77,7 +78,7 @@ def test_token_is_not_blocked_by_value_when_ids_required(user, monkeypatch): payload = JSONWebTokenAuthentication.jwt_create_payload(user) token = JSONWebTokenAuthentication.jwt_encode_payload(payload) - expiration = timezone.now() + timedelta(days=1) + expiration = django_timezone.now() + timedelta(days=1) BlacklistedToken( token=token, expires_at=expiration, @@ -93,7 +94,7 @@ def test_token_is_not_blocked_by_id_when_ids_disabled(user, monkeypatch): payload['jti'] = uuid.uuid4() token = JSONWebTokenAuthentication.jwt_encode_payload(payload) - expiration = timezone.now() + timedelta(days=1) + expiration = django_timezone.now() + timedelta(days=1) BlacklistedToken( token_id=payload['jti'], expires_at=expiration, diff --git a/tests/test_management_commands.py b/tests/test_management_commands.py index 8834ab11..f28349bc 100644 --- a/tests/test_management_commands.py +++ b/tests/test_management_commands.py @@ -4,7 +4,7 @@ from django.conf import settings from django.core.management import call_command -from six import StringIO +from io import StringIO from pytest import raises from rest_framework_jwt.authentication import JSONWebTokenAuthentication diff --git a/tests/views/test_authentication.py b/tests/views/test_authentication.py index 348be6dd..1be7446d 100644 --- a/tests/views/test_authentication.py +++ b/tests/views/test_authentication.py @@ -7,28 +7,20 @@ from collections import OrderedDict from django.utils.encoding import force_str - -try: - from django.utils.translation import ugettext_lazy as _ -except ImportError: - from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from jwt import get_unverified_header from jwt.exceptions import InvalidAlgorithmError, InvalidSignatureError, InvalidTokenError -from pytest import skip, fixture, raises +from pytest import fixture, raises from rest_framework import status from rest_framework_jwt.authentication import JSONWebTokenAuthentication -from rest_framework_jwt.compat import gettext_lazy as _ -from rest_framework_jwt.compat import has_set_cookie_samesite from rest_framework_jwt.settings import api_settings import re -from sys import version_info - @fixture def rsa_keys(scope="session"): @@ -208,9 +200,7 @@ def test_valid_credentials_with_auth_cookie_enabled_returns_jwt_and_cookie( assert setcookie['path'] == '/' assert setcookie['secure'] is True assert setcookie['httponly'] is True # hardcoded - if has_set_cookie_samesite(): - assert setcookie['samesite'] == 'Lax' - + assert setcookie['samesite'] == 'Lax' assert response.status_code == status.HTTP_201_CREATED assert "token" in force_str(response.content) assert auth_cookie in response.client.cookies @@ -237,8 +227,7 @@ def test_auth_cookie_settings( assert setcookie['path'] == '/pa/th' assert 'secure' not in setcookie.items() assert setcookie['httponly'] is True # hardcoded - if has_set_cookie_samesite(): - assert setcookie['samesite'] == 'Strict' + assert setcookie['samesite'] == 'Strict' def test_multi_keys_hash_hash( diff --git a/tests/views/test_integration.py b/tests/views/test_integration.py index 8f450b4a..94c22587 100644 --- a/tests/views/test_integration.py +++ b/tests/views/test_integration.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.utils.translation import gettext_lazy as _ + import base64 import pytest @@ -8,7 +10,6 @@ from rest_framework.reverse import reverse from rest_framework_jwt.authentication import JSONWebTokenAuthentication -from rest_framework_jwt.compat import gettext_lazy as _ from rest_framework_jwt.settings import api_settings diff --git a/tests/views/test_refresh.py b/tests/views/test_refresh.py index 5cd2a8b7..b75829e0 100644 --- a/tests/views/test_refresh.py +++ b/tests/views/test_refresh.py @@ -6,10 +6,10 @@ from datetime import timedelta from django.utils import timezone +from django.utils.translation import gettext_lazy as _ from rest_framework_jwt.authentication import JSONWebTokenAuthentication from rest_framework_jwt.blacklist.models import BlacklistedToken -from rest_framework_jwt.compat import gettext_lazy as _ from rest_framework_jwt.settings import api_settings import uuid diff --git a/tests/views/test_verification.py b/tests/views/test_verification.py index 2c936818..353be498 100644 --- a/tests/views/test_verification.py +++ b/tests/views/test_verification.py @@ -2,8 +2,9 @@ from __future__ import unicode_literals +from django.utils.translation import gettext_lazy as _ + from rest_framework_jwt.authentication import JSONWebTokenAuthentication -from rest_framework_jwt.compat import gettext_lazy as _ from rest_framework_jwt.settings import api_settings diff --git a/tox.ini b/tox.ini index 61b67648..c054f7b0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,18 +1,8 @@ [tox] envlist = - docs,manifest, - py{27,35}-dj111-drf{37,38,39}-pyjwt1-codecov - py{36,37}-dj111-drf{37,38,39}-pyjwt{1,2}-codecov - py35-dj20-drf{37,38,39}-pyjwt1-codecov - py{36,37}-dj20-drf{37,38,39}-pyjwt{1,2}-codecov - py35-dj21-drf{37,38,39,310}-pyjwt1-codecov - py{36,37}-dj21-drf{37,38,39,310}-pyjwt{1,2}-codecov - py35-dj22-drf{37,38,39,310}-pyjwt1-codecov - py{36,37}-dj22-drf{37,38,39,310}-pyjwt{1,2}-codecov - py{36,37,38}-dj30-drf{311}-pyjwt{1,2}-codecov - py{36,37,38}-dj{30,31,32}-drf{311,312}-pyjwt{1,2}-codecov - py{39,310}-dj{30,31,32}-drf{311,312,313}-pyjwt{1,2}-codecov - py{39,310}-dj40-drf313-pyjwt{1,2}-codecov + docs,manifest + py311-dj52-drf316-pyjwt2-codecov + py312-dj52-drf316-pyjwt2-codecov [travis:env] TRAVIS = @@ -22,28 +12,14 @@ TRAVIS = description = run the test suite usedevelop = true passenv = - CI TRAVIS TRAVIS_* + CI,TRAVIS,TRAVIS_* setenv = PYTHONDONTWRITEBYTECODE=1 PYTHONWARNINGS=once PYTHONPATH={toxinidir}/demo deps = - dj111: Django>=1.11,<1.12 - dj20: Django>=2.0,<2.1 - dj21: Django>=2.1,<2.2 - dj22: Django>=2.2,<2.3 - dj30: Django>=3.0,<3.1 - dj31: Django>=3.1,<3.2 - dj32: Django>=3.2,<3.3 - dj40: Django>=4.0,<4.1 - drf37: djangorestframework>=3.7,<3.8 - drf38: djangorestframework>=3.8,<3.9 - drf39: djangorestframework>=3.9,<3.10 - drf310: djangorestframework>=3.10,<3.11 - drf311: djangorestframework>=3.11,<3.12 - drf312: djangorestframework>=3.12,<3.13 - drf313: djangorestframework>=3.13,<3.14 - pyjwt1: PyJWT[crypto]>=1.5.2,<2.0.0 + dj52: Django>=5.2,<5.3 + drf316: djangorestframework>=3.16,<3.17 pyjwt2: PyJWT[crypto]>=2.0.0,<3.0.0 cryptography<3.4 # Avoiding the "needs Rust" versions coverage: coverage @@ -53,6 +29,7 @@ deps = whitelist_externals = /bin/bash /usr/bin/bash +allowlist_externals = bash commands = pytest {posargs} --cov=rest_framework_jwt codecov: bash -ec 'flags={envname}; flags="$\{flags//-/,\}"; codecov --name={envname} --flags="$flags"' @@ -61,7 +38,7 @@ extras = [testenv:docs] description = build the documentation -basepython = python3.6 +basepython = python3.11 commands = mkdocs {posargs:build} extras = test @@ -69,7 +46,7 @@ extras = [testenv:changelog] description = build the changelog -basepython = python3 +basepython = python3.11 deps = towncrier==18.6.0 skip_install = true @@ -78,14 +55,14 @@ commands = towncrier {posargs} [testenv:manifest] -basepython = python3 +basepython = python3.11 deps = check-manifest skip_install = true commands = check-manifest [testenv:release] description = build the changelog, bump the package version, commit and tag -basepython=python2.7 +basepython=python3.11 skip_install = true whitelist_externals = git @@ -111,12 +88,13 @@ setenv = extras = dev commands = + python manage.py makemigrations + python manage.py migrate python manage.py runserver {posargs} - [testenv:deploy] description = build the package and deploy it to PyPI.org -basepython = python3 +basepython = python3.11 isolated_build = True skip_install = true setenv =