From 940f5666e22c273826ab0581c678f5296444af4b Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Thu, 29 Aug 2024 13:04:44 +0100 Subject: [PATCH 01/11] Add official support for Django 5.1 Following the supported Python versions: https://docs.djangoproject.com/en/stable/faq/install/ --- README.md | 2 +- docs/index.md | 2 +- setup.py | 1 + tox.ini | 13 ++++--------- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index fc44a461e6..6e62fb39a1 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Some reasons you might want to use REST framework: # Requirements * Python 3.8+ -* Django 5.0, 4.2 +* Django 4.2, 5.0, 5.1 We **highly recommend** and only officially support the latest patch release of each Python and Django series. diff --git a/docs/index.md b/docs/index.md index 719eb6e6ef..0f809ec07a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -87,7 +87,7 @@ continued development by **[signing up for a paid plan][funding]**. REST framework requires the following: -* Django (4.2, 5.0) +* Django (4.2, 5.0, 5.1) * Python (3.8, 3.9, 3.10, 3.11, 3.12) We **highly recommend** and only officially support the latest patch release of diff --git a/setup.py b/setup.py index 568909bbc5..08ef6df88c 100755 --- a/setup.py +++ b/setup.py @@ -91,6 +91,7 @@ def get_version(package): 'Framework :: Django', 'Framework :: Django :: 4.2', 'Framework :: Django :: 5.0', + 'Framework :: Django :: 5.1', 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', diff --git a/tox.ini b/tox.ini index 16cc3f8f44..eee1de4902 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,9 @@ [tox] envlist = {py38,py39}-{django42} - {py310}-{django42,django50,djangomain} - {py311}-{django42,django50,djangomain} - {py312}-{django42,django50,djangomain} + {py310}-{django42,django50,django51,djangomain} + {py311}-{django42,django50,django51,djangomain} + {py312}-{django42,django50,django51,djangomain} base dist docs @@ -17,6 +17,7 @@ setenv = deps = django42: Django>=4.2,<5.0 django50: Django>=5.0,<5.1 + django51: Django>=5.1,<5.2 djangomain: https://github.com/django/django/archive/main.tar.gz -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt @@ -42,12 +43,6 @@ deps = -rrequirements/requirements-testing.txt -rrequirements/requirements-documentation.txt -[testenv:py38-djangomain] -ignore_outcome = true - -[testenv:py39-djangomain] -ignore_outcome = true - [testenv:py310-djangomain] ignore_outcome = true From bf0039da63391448a233a329c4905c28b13b228f Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Fri, 30 Aug 2024 10:30:23 +0100 Subject: [PATCH 02/11] Add tests to cover compat with Django's 5.1 LoginRequiredMiddleware --- tests/test_middleware.py | 49 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 6b2c91db72..b8733b7dd2 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,9 +1,16 @@ +import base64 +import unittest + +import django from django.contrib.auth.models import User from django.http import HttpRequest from django.test import override_settings from django.urls import path -from rest_framework.authentication import TokenAuthentication +from rest_framework import HTTP_HEADER_ENCODING +from rest_framework.authentication import ( + BasicAuthentication, TokenAuthentication +) from rest_framework.authtoken.models import Token from rest_framework.request import is_form_media_type from rest_framework.response import Response @@ -18,6 +25,7 @@ def post(self, request): urlpatterns = [ path('auth', APIView.as_view(authentication_classes=(TokenAuthentication,))), + path('basic', APIView.as_view(authentication_classes=(BasicAuthentication,))), path('post', PostView.as_view()), ] @@ -74,3 +82,42 @@ def test_middleware_can_access_request_post_when_processing_response(self): response = self.client.post('/post', {'foo': 'bar'}, format='json') assert response.status_code == 200 + + +@unittest.skipUnless(django.VERSION >= (5, 1), 'Only for Django 5.1+') +@override_settings( + ROOT_URLCONF='tests.test_middleware', + MIDDLEWARE=( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.auth.middleware.LoginRequiredMiddleware', + ), +) +class TestLoginRequiredMiddleware(APITestCase): + def test_redirects_to_login_when_user_is_anonymous(self): + response = self.client.post('/post') + self.assertRedirects(response, '/accounts/login/?next=/post', fetch_redirect_response=False) + + def test_process_request_when_session_authenticated(self): + user = User.objects.create_user('john', 'john@example.com', 'password') + self.client.force_login(user) + + response = self.client.post('/post') + assert response.status_code == 200 + + def test_compat_with_token_auth(self): + user = User.objects.create_user('john', 'john@example.com', 'password') + key = 'abcd1234' + Token.objects.create(key=key, user=user) + + response = self.client.get('/auth', HTTP_AUTHORIZATION='Token %s' % key) + assert response.status_code == 200 + + def test_compat_with_basic_auth(self): + user = User.objects.create_user('john', 'john@example.com', 'password') + credentials = ('%s:%s' % (user.username, user.password)) + base64_credentials = base64.b64encode( + credentials.encode(HTTP_HEADER_ENCODING) + ).decode(HTTP_HEADER_ENCODING) + response = self.client.get('/basic', HTTP_AUTHORIZATION='Basic %s' % base64_credentials) + assert response.status_code == 200 From 48616f6f289f0a0af874152c5542b7d5981dae2a Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Fri, 30 Aug 2024 18:13:54 +0100 Subject: [PATCH 03/11] First pass to create DRF's LoginRequiredMiddleware --- rest_framework/middleware.py | 26 ++++++++++++ tests/test_middleware.py | 78 ++++++++++++++++++++++++++---------- 2 files changed, 82 insertions(+), 22 deletions(-) create mode 100644 rest_framework/middleware.py diff --git a/rest_framework/middleware.py b/rest_framework/middleware.py new file mode 100644 index 0000000000..1385c76949 --- /dev/null +++ b/rest_framework/middleware.py @@ -0,0 +1,26 @@ +from django.core.exceptions import ImproperlyConfigured + +from rest_framework.settings import api_settings +from rest_framework.views import APIView + +try: + from django.contrib.auth.middleware import \ + LoginRequiredMiddleware as DjangoLoginRequiredMiddleware +except ImportError: + DjangoLoginRequiredMiddleware = None + + +if DjangoLoginRequiredMiddleware: + class LoginRequiredMiddleware(DjangoLoginRequiredMiddleware): + def process_view(self, request, view_func, view_args, view_kwargs): + if ( + hasattr(view_func, "cls") + and issubclass(view_func.cls, APIView) + ): + if 'rest_framework.permissions.AllowAny' in api_settings.DEFAULT_PERMISSION_CLASSES: + raise ImproperlyConfigured( + "You cannot use 'rest_framework.permissions.AllowAny' in `DEFAULT_PERMISSION_CLASSES` " + "with `LoginRequiredMiddleware`." + ) + return None + return super().process_view(request, view_func, view_args, view_kwargs) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index b8733b7dd2..e1c1de6f63 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -3,11 +3,12 @@ import django from django.contrib.auth.models import User -from django.http import HttpRequest +from django.http import HttpRequest, HttpResponse from django.test import override_settings from django.urls import path +from django.views import View -from rest_framework import HTTP_HEADER_ENCODING +from rest_framework import HTTP_HEADER_ENCODING, status from rest_framework.authentication import ( BasicAuthentication, TokenAuthentication ) @@ -18,15 +19,28 @@ from rest_framework.views import APIView -class PostView(APIView): +class PostAPIView(APIView): def post(self, request): return Response(data=request.data, status=200) +class GetAPIView(APIView): + def get(self, request): + return Response(data={"status": "ok"}, status=200) + + +class GetView(View): + def get(self, request): + return HttpResponse("OK", status=200) + + urlpatterns = [ - path('auth', APIView.as_view(authentication_classes=(TokenAuthentication,))), - path('basic', APIView.as_view(authentication_classes=(BasicAuthentication,))), - path('post', PostView.as_view()), + path('api/auth', APIView.as_view(authentication_classes=(TokenAuthentication,))), + path('api/post', PostAPIView.as_view()), + path('api/get', GetAPIView.as_view()), + path('api/basic', GetAPIView.as_view(authentication_classes=(BasicAuthentication,))), + path('api/token', GetAPIView.as_view(authentication_classes=(TokenAuthentication,))), + path('get', GetView.as_view()), ] @@ -73,14 +87,14 @@ def test_middleware_can_access_user_when_processing_response(self): key = 'abcd1234' Token.objects.create(key=key, user=user) - self.client.get('/auth', HTTP_AUTHORIZATION='Token %s' % key) + self.client.get('/api/auth', HTTP_AUTHORIZATION='Token %s' % key) @override_settings(MIDDLEWARE=('tests.test_middleware.RequestPOSTMiddleware',)) def test_middleware_can_access_request_post_when_processing_response(self): - response = self.client.post('/post', {'foo': 'bar'}) + response = self.client.post('/api/post', {'foo': 'bar'}) assert response.status_code == 200 - response = self.client.post('/post', {'foo': 'bar'}, format='json') + response = self.client.post('/api/post', {'foo': 'bar'}, format='json') assert response.status_code == 200 @@ -88,36 +102,56 @@ def test_middleware_can_access_request_post_when_processing_response(self): @override_settings( ROOT_URLCONF='tests.test_middleware', MIDDLEWARE=( + # Needed for AuthenticationMiddleware 'django.contrib.sessions.middleware.SessionMiddleware', + # Needed for LoginRequiredMiddleware 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.auth.middleware.LoginRequiredMiddleware', + 'rest_framework.middleware.LoginRequiredMiddleware', ), + REST_FRAMEWORK={ + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + } ) class TestLoginRequiredMiddleware(APITestCase): - def test_redirects_to_login_when_user_is_anonymous(self): - response = self.client.post('/post') - self.assertRedirects(response, '/accounts/login/?next=/post', fetch_redirect_response=False) + def test_unauthorized_when_user_is_anonymous_on_public_view(self): + response = self.client.get('/api/get') + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_unauthorized_when_user_is_anonymous_on_basic_auth_view(self): + response = self.client.get('/api/basic') + assert response.status_code == status.HTTP_401_UNAUTHORIZED - def test_process_request_when_session_authenticated(self): + def test_unauthorized_when_user_is_anonymous_on_token_auth_view(self): + response = self.client.get('/api/token') + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_allows_request_when_session_authenticated(self): user = User.objects.create_user('john', 'john@example.com', 'password') self.client.force_login(user) - response = self.client.post('/post') - assert response.status_code == 200 + response = self.client.get('/api/get') + assert response.status_code == status.HTTP_200_OK - def test_compat_with_token_auth(self): + def test_allows_request_when_token_authenticated(self): user = User.objects.create_user('john', 'john@example.com', 'password') key = 'abcd1234' Token.objects.create(key=key, user=user) - response = self.client.get('/auth', HTTP_AUTHORIZATION='Token %s' % key) - assert response.status_code == 200 + response = self.client.get('/api/token', headers={"Authorization": f'Token {key}'}) + assert response.status_code == status.HTTP_200_OK - def test_compat_with_basic_auth(self): + def test_allows_request_when_basic_authenticated(self): user = User.objects.create_user('john', 'john@example.com', 'password') credentials = ('%s:%s' % (user.username, user.password)) base64_credentials = base64.b64encode( credentials.encode(HTTP_HEADER_ENCODING) ).decode(HTTP_HEADER_ENCODING) - response = self.client.get('/basic', HTTP_AUTHORIZATION='Basic %s' % base64_credentials) - assert response.status_code == 200 + auth = f'Basic {base64_credentials}' + response = self.client.get('/api/basic', headers={"Authorization": auth}) + assert response.status_code == status.HTTP_200_OK + + def test_works_as_base_middleware_for_django_view(self): + response = self.client.get('/get') + self.assertRedirects(response, '/accounts/login/?next=/get', fetch_redirect_response=False) From 6b55ccc22a66b2341817ea1f8a83c607218cdca4 Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Fri, 30 Aug 2024 18:41:09 +0100 Subject: [PATCH 04/11] Attempt to fix the tests --- tests/test_middleware.py | 46 ++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index e1c1de6f63..09c24d523c 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -13,6 +13,7 @@ BasicAuthentication, TokenAuthentication ) from rest_framework.authtoken.models import Token +from rest_framework.decorators import api_view from rest_framework.request import is_form_media_type from rest_framework.response import Response from rest_framework.test import APITestCase @@ -24,24 +25,34 @@ def post(self, request): return Response(data=request.data, status=200) -class GetAPIView(APIView): - def get(self, request): - return Response(data={"status": "ok"}, status=200) +with override_settings( + REST_FRAMEWORK={ + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + } +): + class GetAPIView(APIView): + def get(self, request): + return Response(data={"status": "ok"}, status=200) + class GetView(View): + def get(self, request): + return HttpResponse("OK", status=200) -class GetView(View): - def get(self, request): + @api_view(['GET']) + def get_func_view(request): return HttpResponse("OK", status=200) - -urlpatterns = [ - path('api/auth', APIView.as_view(authentication_classes=(TokenAuthentication,))), - path('api/post', PostAPIView.as_view()), - path('api/get', GetAPIView.as_view()), - path('api/basic', GetAPIView.as_view(authentication_classes=(BasicAuthentication,))), - path('api/token', GetAPIView.as_view(authentication_classes=(TokenAuthentication,))), - path('get', GetView.as_view()), -] + urlpatterns = [ + path('api/auth', APIView.as_view(authentication_classes=(TokenAuthentication,))), + path('api/post', PostAPIView.as_view()), + path('api/get', GetAPIView.as_view()), + path('api/get-func', get_func_view), + path('api/basic', GetAPIView.as_view(authentication_classes=(BasicAuthentication,))), + path('api/token', GetAPIView.as_view(authentication_classes=(TokenAuthentication,))), + path('get', GetView.as_view()), + ] class RequestUserMiddleware: @@ -134,6 +145,13 @@ def test_allows_request_when_session_authenticated(self): response = self.client.get('/api/get') assert response.status_code == status.HTTP_200_OK + def test_allows_request_when_authenticated_function_view(self): + user = User.objects.create_user('john', 'john@example.com', 'password') + self.client.force_login(user) + + response = self.client.get('/api/get-func') + assert response.status_code == status.HTTP_200_OK + def test_allows_request_when_token_authenticated(self): user = User.objects.create_user('john', 'john@example.com', 'password') key = 'abcd1234' From d295dfe5a7f43f2e79e2f01ad54624c76906a8df Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Fri, 30 Aug 2024 18:42:14 +0100 Subject: [PATCH 05/11] Revert custom middleware implementation --- rest_framework/middleware.py | 26 -------- tests/test_middleware.py | 119 +++-------------------------------- 2 files changed, 10 insertions(+), 135 deletions(-) delete mode 100644 rest_framework/middleware.py diff --git a/rest_framework/middleware.py b/rest_framework/middleware.py deleted file mode 100644 index 1385c76949..0000000000 --- a/rest_framework/middleware.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.core.exceptions import ImproperlyConfigured - -from rest_framework.settings import api_settings -from rest_framework.views import APIView - -try: - from django.contrib.auth.middleware import \ - LoginRequiredMiddleware as DjangoLoginRequiredMiddleware -except ImportError: - DjangoLoginRequiredMiddleware = None - - -if DjangoLoginRequiredMiddleware: - class LoginRequiredMiddleware(DjangoLoginRequiredMiddleware): - def process_view(self, request, view_func, view_args, view_kwargs): - if ( - hasattr(view_func, "cls") - and issubclass(view_func.cls, APIView) - ): - if 'rest_framework.permissions.AllowAny' in api_settings.DEFAULT_PERMISSION_CLASSES: - raise ImproperlyConfigured( - "You cannot use 'rest_framework.permissions.AllowAny' in `DEFAULT_PERMISSION_CLASSES` " - "with `LoginRequiredMiddleware`." - ) - return None - return super().process_view(request, view_func, view_args, view_kwargs) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 09c24d523c..6b2c91db72 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,58 +1,25 @@ -import base64 -import unittest - -import django from django.contrib.auth.models import User -from django.http import HttpRequest, HttpResponse +from django.http import HttpRequest from django.test import override_settings from django.urls import path -from django.views import View -from rest_framework import HTTP_HEADER_ENCODING, status -from rest_framework.authentication import ( - BasicAuthentication, TokenAuthentication -) +from rest_framework.authentication import TokenAuthentication from rest_framework.authtoken.models import Token -from rest_framework.decorators import api_view from rest_framework.request import is_form_media_type from rest_framework.response import Response from rest_framework.test import APITestCase from rest_framework.views import APIView -class PostAPIView(APIView): +class PostView(APIView): def post(self, request): return Response(data=request.data, status=200) -with override_settings( - REST_FRAMEWORK={ - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated', - ], - } -): - class GetAPIView(APIView): - def get(self, request): - return Response(data={"status": "ok"}, status=200) - - class GetView(View): - def get(self, request): - return HttpResponse("OK", status=200) - - @api_view(['GET']) - def get_func_view(request): - return HttpResponse("OK", status=200) - - urlpatterns = [ - path('api/auth', APIView.as_view(authentication_classes=(TokenAuthentication,))), - path('api/post', PostAPIView.as_view()), - path('api/get', GetAPIView.as_view()), - path('api/get-func', get_func_view), - path('api/basic', GetAPIView.as_view(authentication_classes=(BasicAuthentication,))), - path('api/token', GetAPIView.as_view(authentication_classes=(TokenAuthentication,))), - path('get', GetView.as_view()), - ] +urlpatterns = [ + path('auth', APIView.as_view(authentication_classes=(TokenAuthentication,))), + path('post', PostView.as_view()), +] class RequestUserMiddleware: @@ -98,78 +65,12 @@ def test_middleware_can_access_user_when_processing_response(self): key = 'abcd1234' Token.objects.create(key=key, user=user) - self.client.get('/api/auth', HTTP_AUTHORIZATION='Token %s' % key) + self.client.get('/auth', HTTP_AUTHORIZATION='Token %s' % key) @override_settings(MIDDLEWARE=('tests.test_middleware.RequestPOSTMiddleware',)) def test_middleware_can_access_request_post_when_processing_response(self): - response = self.client.post('/api/post', {'foo': 'bar'}) + response = self.client.post('/post', {'foo': 'bar'}) assert response.status_code == 200 - response = self.client.post('/api/post', {'foo': 'bar'}, format='json') + response = self.client.post('/post', {'foo': 'bar'}, format='json') assert response.status_code == 200 - - -@unittest.skipUnless(django.VERSION >= (5, 1), 'Only for Django 5.1+') -@override_settings( - ROOT_URLCONF='tests.test_middleware', - MIDDLEWARE=( - # Needed for AuthenticationMiddleware - 'django.contrib.sessions.middleware.SessionMiddleware', - # Needed for LoginRequiredMiddleware - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'rest_framework.middleware.LoginRequiredMiddleware', - ), - REST_FRAMEWORK={ - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated', - ], - } -) -class TestLoginRequiredMiddleware(APITestCase): - def test_unauthorized_when_user_is_anonymous_on_public_view(self): - response = self.client.get('/api/get') - assert response.status_code == status.HTTP_401_UNAUTHORIZED - - def test_unauthorized_when_user_is_anonymous_on_basic_auth_view(self): - response = self.client.get('/api/basic') - assert response.status_code == status.HTTP_401_UNAUTHORIZED - - def test_unauthorized_when_user_is_anonymous_on_token_auth_view(self): - response = self.client.get('/api/token') - assert response.status_code == status.HTTP_401_UNAUTHORIZED - - def test_allows_request_when_session_authenticated(self): - user = User.objects.create_user('john', 'john@example.com', 'password') - self.client.force_login(user) - - response = self.client.get('/api/get') - assert response.status_code == status.HTTP_200_OK - - def test_allows_request_when_authenticated_function_view(self): - user = User.objects.create_user('john', 'john@example.com', 'password') - self.client.force_login(user) - - response = self.client.get('/api/get-func') - assert response.status_code == status.HTTP_200_OK - - def test_allows_request_when_token_authenticated(self): - user = User.objects.create_user('john', 'john@example.com', 'password') - key = 'abcd1234' - Token.objects.create(key=key, user=user) - - response = self.client.get('/api/token', headers={"Authorization": f'Token {key}'}) - assert response.status_code == status.HTTP_200_OK - - def test_allows_request_when_basic_authenticated(self): - user = User.objects.create_user('john', 'john@example.com', 'password') - credentials = ('%s:%s' % (user.username, user.password)) - base64_credentials = base64.b64encode( - credentials.encode(HTTP_HEADER_ENCODING) - ).decode(HTTP_HEADER_ENCODING) - auth = f'Basic {base64_credentials}' - response = self.client.get('/api/basic', headers={"Authorization": auth}) - assert response.status_code == status.HTTP_200_OK - - def test_works_as_base_middleware_for_django_view(self): - response = self.client.get('/get') - self.assertRedirects(response, '/accounts/login/?next=/get', fetch_redirect_response=False) From dee68ecfceeb894d5de311ddb539955f40263dd2 Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Fri, 30 Aug 2024 18:42:43 +0100 Subject: [PATCH 06/11] Disable LoginRequiredMiddleware on DRF views --- rest_framework/decorators.py | 6 ++++++ rest_framework/views.py | 6 ++++++ tests/test_views.py | 10 ++++++++++ 3 files changed, 22 insertions(+) diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index 864ff73958..b3a9cdfeb8 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -8,6 +8,7 @@ """ import types +from django import VERSION as DJANGO_VERSION from django.forms.utils import pretty_name from rest_framework.views import APIView @@ -73,6 +74,11 @@ def handler(self, *args, **kwargs): WrappedAPIView.schema = getattr(func, 'schema', APIView.schema) + # Exempt all DRF views from Django's LoginRequiredMiddleware. Users should set + # DEFAULT_PERMISSION_CLASSES to 'rest_framework.permissions.IsAuthenticated' instead + if DJANGO_VERSION >= (5, 1): + func.login_required = False + return WrappedAPIView.as_view() return decorator diff --git a/rest_framework/views.py b/rest_framework/views.py index 411c1ee384..327ebe9032 100644 --- a/rest_framework/views.py +++ b/rest_framework/views.py @@ -1,6 +1,7 @@ """ Provides an APIView class that is the base of all views in REST framework. """ +from django import VERSION as DJANGO_VERSION from django.conf import settings from django.core.exceptions import PermissionDenied from django.db import connections, models @@ -139,6 +140,11 @@ def force_evaluation(): view.cls = cls view.initkwargs = initkwargs + # Exempt all DRF views from Django's LoginRequiredMiddleware. Users should set + # DEFAULT_PERMISSION_CLASSES to 'rest_framework.permissions.IsAuthenticated' instead + if DJANGO_VERSION >= (5, 1): + view.login_required = False + # Note: session based authentication is explicitly CSRF validated, # all other authentication is CSRF exempt. return csrf_exempt(view) diff --git a/tests/test_views.py b/tests/test_views.py index 2648c9fb38..4fd39d368d 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,5 +1,7 @@ import copy +import unittest +from django import VERSION as DJANGO_VERSION from django.test import TestCase from rest_framework import status @@ -81,6 +83,10 @@ def test_400_parse_error(self): assert response.status_code == status.HTTP_400_BAD_REQUEST assert sanitise_json_error(response.data) == expected + @unittest.skipUnless(DJANGO_VERSION >= (5, 1), 'Only for Django 5.1+') + def test_django_51_login_required_disabled(self): + assert self.view.login_required is False + class FunctionBasedViewIntegrationTests(TestCase): def setUp(self): @@ -95,6 +101,10 @@ def test_400_parse_error(self): assert response.status_code == status.HTTP_400_BAD_REQUEST assert sanitise_json_error(response.data) == expected + @unittest.skipUnless(DJANGO_VERSION >= (5, 1), 'Only for Django 5.1+') + def test_django_51_login_required_disabled(self): + assert self.view.login_required is False + class TestCustomExceptionHandler(TestCase): def setUp(self): From 7f9caf52ce6cbe9029235591071bbb9985771a6b Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Fri, 30 Aug 2024 18:54:53 +0100 Subject: [PATCH 07/11] Document how to integrate DRF with LoginRequiredMiddleware --- docs/api-guide/authentication.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index d6e6293fd9..8409a83c87 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -90,6 +90,12 @@ The kind of response that will be used depends on the authentication scheme. Al Note that when a request may successfully authenticate, but still be denied permission to perform the request, in which case a `403 Permission Denied` response will always be used, regardless of the authentication scheme. +## Django 5.1+ `LoginRequiredMiddleware` + +If you're running Django 5.1+ and use the [`LoginRequiredMiddleware`][login-required-middleware], please note that all views from DRF are opted-out of this middleware. This is because the authentication in DRF is based authentication and permissions classes, which may be determined after the middleware has been applied. Additionally, when the request is not authenticated, the middleware redirects the user to the login page, which is not suitable for API requests, where it's preferable to return a 401 status code. + +REST framework offers an equivalent mechanism for DRF views via the global settings, `DEFAULT_AUTHENTICATION_CLASSES` and `DEFAULT_PERMISSION_CLASSES`. They should be changed accordingly if you need to enforce that API requests are logged in. + ## Apache mod_wsgi specific configuration Note that if deploying to [Apache using mod_wsgi][mod_wsgi_official], the authorization header is not passed through to a WSGI application by default, as it is assumed that authentication will be handled by Apache, rather than at an application level. @@ -484,3 +490,4 @@ More information can be found in the [Documentation](https://django-rest-durin.r [drfpasswordless]: https://github.com/aaronn/django-rest-framework-passwordless [django-rest-authemail]: https://github.com/celiao/django-rest-authemail [django-rest-durin]: https://github.com/eshaan7/django-rest-durin +[login-required-middleware]: https://docs.djangoproject.com/en/stable/ref/middleware/#django.contrib.auth.middleware.LoginRequiredMiddleware \ No newline at end of file From 63ce5c9414ab986bafed847731f5346365ca06f3 Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Fri, 30 Aug 2024 19:15:41 +0100 Subject: [PATCH 08/11] Move login required tests under a separate test case --- tests/test_views.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/test_views.py b/tests/test_views.py index 4fd39d368d..11f24906ea 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -83,10 +83,6 @@ def test_400_parse_error(self): assert response.status_code == status.HTTP_400_BAD_REQUEST assert sanitise_json_error(response.data) == expected - @unittest.skipUnless(DJANGO_VERSION >= (5, 1), 'Only for Django 5.1+') - def test_django_51_login_required_disabled(self): - assert self.view.login_required is False - class FunctionBasedViewIntegrationTests(TestCase): def setUp(self): @@ -101,10 +97,6 @@ def test_400_parse_error(self): assert response.status_code == status.HTTP_400_BAD_REQUEST assert sanitise_json_error(response.data) == expected - @unittest.skipUnless(DJANGO_VERSION >= (5, 1), 'Only for Django 5.1+') - def test_django_51_login_required_disabled(self): - assert self.view.login_required is False - class TestCustomExceptionHandler(TestCase): def setUp(self): @@ -146,3 +138,13 @@ def test_get_exception_handler(self): response = self.view(request) assert response.status_code == 400 assert response.data == {'error': 'SyntaxError'} + + +@unittest.skipUnless(DJANGO_VERSION >= (5, 1), 'Only for Django 5.1+') +class TestLoginRequiredMiddlewareCompat(TestCase): + def test_class_based_view_opted_out(self): + class_based_view = BasicView.as_view() + assert class_based_view.login_required is False + + def test_function_based_view_opted_out(self): + assert basic_view.login_required is False From f7e8556eec0bc01b0224282e57f03978c6955286 Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Fri, 30 Aug 2024 22:52:55 +0100 Subject: [PATCH 09/11] Revert redundant change --- rest_framework/decorators.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index b3a9cdfeb8..864ff73958 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -8,7 +8,6 @@ """ import types -from django import VERSION as DJANGO_VERSION from django.forms.utils import pretty_name from rest_framework.views import APIView @@ -74,11 +73,6 @@ def handler(self, *args, **kwargs): WrappedAPIView.schema = getattr(func, 'schema', APIView.schema) - # Exempt all DRF views from Django's LoginRequiredMiddleware. Users should set - # DEFAULT_PERMISSION_CLASSES to 'rest_framework.permissions.IsAuthenticated' instead - if DJANGO_VERSION >= (5, 1): - func.login_required = False - return WrappedAPIView.as_view() return decorator From 2458547b96d50a83e22c53cacc3bfb27cebf3817 Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Fri, 30 Aug 2024 23:03:58 +0100 Subject: [PATCH 10/11] Disable LoginRequiredMiddleware on ViewSets --- rest_framework/viewsets.py | 7 +++++++ tests/test_viewsets.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py index 2eba17b4a3..a9c90a8d9f 100644 --- a/rest_framework/viewsets.py +++ b/rest_framework/viewsets.py @@ -19,6 +19,7 @@ from functools import update_wrapper from inspect import getmembers +from django import VERSION as DJANGO_VERSION from django.urls import NoReverseMatch from django.utils.decorators import classonlymethod from django.views.decorators.csrf import csrf_exempt @@ -136,6 +137,12 @@ def view(request, *args, **kwargs): view.cls = cls view.initkwargs = initkwargs view.actions = actions + + # Exempt from Django's LoginRequiredMiddleware. Users should set + # DEFAULT_PERMISSION_CLASSES to 'rest_framework.permissions.IsAuthenticated' instead + if DJANGO_VERSION >= (5, 1): + view.login_required = False + return csrf_exempt(view) def initialize_request(self, request, *args, **kwargs): diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py index 8e439c86eb..68b1207c39 100644 --- a/tests/test_viewsets.py +++ b/tests/test_viewsets.py @@ -1,6 +1,8 @@ +import unittest from functools import wraps import pytest +from django import VERSION as DJANGO_VERSION from django.db import models from django.test import TestCase, override_settings from django.urls import include, path @@ -196,6 +198,11 @@ def test_viewset_action_attr_for_extra_action(self): assert get.view.action == 'list_action' assert head.view.action == 'list_action' + @unittest.skipUnless(DJANGO_VERSION >= (5, 1), 'Only for Django 5.1+') + def test_login_required_middleware_compat(self): + view = ActionViewSet.as_view(actions={'get': 'list'}) + assert view.login_required is False + class GetExtraActionsTests(TestCase): From 801304fa5496e45360096dcfa2713687f66c11e7 Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Sat, 31 Aug 2024 10:31:36 +0100 Subject: [PATCH 11/11] Add some integrations tests to cover various view types --- tests/test_middleware.py | 74 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 6b2c91db72..11d4bc01eb 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,14 +1,21 @@ +import unittest + +import django from django.contrib.auth.models import User from django.http import HttpRequest from django.test import override_settings -from django.urls import path +from django.urls import include, path +from rest_framework import status from rest_framework.authentication import TokenAuthentication from rest_framework.authtoken.models import Token +from rest_framework.decorators import action, api_view from rest_framework.request import is_form_media_type from rest_framework.response import Response +from rest_framework.routers import SimpleRouter from rest_framework.test import APITestCase from rest_framework.views import APIView +from rest_framework.viewsets import GenericViewSet class PostView(APIView): @@ -16,9 +23,39 @@ def post(self, request): return Response(data=request.data, status=200) +class GetAPIView(APIView): + def get(self, request): + return Response(data="OK", status=200) + + +@api_view(['GET']) +def get_func_view(request): + return Response(data="OK", status=200) + + +class ListViewSet(GenericViewSet): + + def list(self, request, *args, **kwargs): + response = Response() + response.view = self + return response + + @action(detail=False, url_path='list-action') + def list_action(self, request, *args, **kwargs): + response = Response() + response.view = self + return response + + +router = SimpleRouter() +router.register(r'view-set', ListViewSet, basename='view_set') + urlpatterns = [ path('auth', APIView.as_view(authentication_classes=(TokenAuthentication,))), path('post', PostView.as_view()), + path('get', GetAPIView.as_view()), + path('get-func', get_func_view), + path('api/', include(router.urls)), ] @@ -74,3 +111,38 @@ def test_middleware_can_access_request_post_when_processing_response(self): response = self.client.post('/post', {'foo': 'bar'}, format='json') assert response.status_code == 200 + + +@unittest.skipUnless(django.VERSION >= (5, 1), 'Only for Django 5.1+') +@override_settings( + ROOT_URLCONF='tests.test_middleware', + MIDDLEWARE=( + # Needed for AuthenticationMiddleware + 'django.contrib.sessions.middleware.SessionMiddleware', + # Needed for LoginRequiredMiddleware + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.auth.middleware.LoginRequiredMiddleware', + ), +) +class TestLoginRequiredMiddlewareCompat(APITestCase): + """ + Django's 5.1+ LoginRequiredMiddleware should NOT apply to DRF views. + + Instead, users should put IsAuthenticated in their + DEFAULT_PERMISSION_CLASSES setting. + """ + def test_class_based_view(self): + response = self.client.get('/get') + assert response.status_code == status.HTTP_200_OK + + def test_function_based_view(self): + response = self.client.get('/get-func') + assert response.status_code == status.HTTP_200_OK + + def test_viewset_list(self): + response = self.client.get('/api/view-set/') + assert response.status_code == status.HTTP_200_OK + + def test_viewset_list_action(self): + response = self.client.get('/api/view-set/list-action/') + assert response.status_code == status.HTTP_200_OK