Browse Source

Fixed #15619 -- Deprecated log out via GET requests.

Thanks Florian Apolloner for the implementation idea.

Co-Authored-By: Mariusz Felisiak <felisiak.mariusz@gmail.com>
René Fleschenberg 5 years ago
parent
commit
eb07b5be0c

+ 21 - 4
django/contrib/auth/views.py

@@ -1,3 +1,4 @@
+import warnings
 from urllib.parse import urlparse, urlunparse
 
 from django.conf import settings
@@ -21,6 +22,7 @@ from django.http import HttpResponseRedirect, QueryDict
 from django.shortcuts import resolve_url
 from django.urls import reverse_lazy
 from django.utils.decorators import method_decorator
+from django.utils.deprecation import RemovedInDjango50Warning
 from django.utils.http import url_has_allowed_host_and_scheme, urlsafe_base64_decode
 from django.utils.translation import gettext_lazy as _
 from django.views.decorators.cache import never_cache
@@ -117,23 +119,38 @@ class LogoutView(SuccessURLAllowedHostsMixin, TemplateView):
     Log out the user and display the 'You are logged out' message.
     """
 
+    # RemovedInDjango50Warning: when the deprecation ends, remove "get" and
+    # "head" from http_method_names.
+    http_method_names = ["get", "head", "post", "options"]
     next_page = None
     redirect_field_name = REDIRECT_FIELD_NAME
     template_name = "registration/logged_out.html"
     extra_context = None
 
+    # RemovedInDjango50Warning: when the deprecation ends, move
+    # @method_decorator(csrf_protect) from post() to dispatch().
     @method_decorator(never_cache)
     def dispatch(self, request, *args, **kwargs):
+        if request.method.lower() == "get":
+            warnings.warn(
+                "Log out via GET requests is deprecated and will be removed in Django "
+                "5.0. Use POST requests for logging out.",
+                RemovedInDjango50Warning,
+            )
+        return super().dispatch(request, *args, **kwargs)
+
+    @method_decorator(csrf_protect)
+    def post(self, request, *args, **kwargs):
+        """Logout may be done via POST."""
         auth_logout(request)
         next_page = self.get_next_page()
         if next_page:
             # Redirect to this page until the session has been cleared.
             return HttpResponseRedirect(next_page)
-        return super().dispatch(request, *args, **kwargs)
+        return super().get(request, *args, **kwargs)
 
-    def post(self, request, *args, **kwargs):
-        """Logout may be done via POST."""
-        return self.get(request, *args, **kwargs)
+    # RemovedInDjango50Warning.
+    get = post
 
     def get_next_page(self):
         if self.next_page is not None:

+ 4 - 0
docs/internals/deprecation.txt

@@ -90,6 +90,10 @@ details on these changes.
 * ``created=True`` will be required in the signature of
   ``RemoteUserBackend.configure_user()`` subclasses.
 
+* Support for logging out via ``GET`` requests in the
+  ``django.contrib.auth.views.LogoutView`` and
+  ``django.contrib.auth.views.logout_then_login()`` will be removed.
+
 .. _deprecation-removed-in-4.1:
 
 4.1

+ 30 - 0
docs/releases/4.1.txt

@@ -446,6 +446,36 @@ Miscellaneous
 Features deprecated in 4.1
 ==========================
 
+Log out via GET
+---------------
+
+Logging out via ``GET`` requests to the :py:class:`built-in logout view
+<django.contrib.auth.views.LogoutView>` is deprecated. Use ``POST`` requests
+instead.
+
+If you want to retain the user experience of an HTML link, you can use a form
+that is styled to appear as a link:
+
+.. code-block:: html
+
+  <form id="logout-form" method="post" action="{% url 'admin:logout' %}">
+    {% csrf_token %}
+    <button type="submit">{% translate "Log out" %}</button>
+  </form>
+
+.. code-block:: css
+
+  #logout-form {
+    display: inline;
+  }
+  #logout-form button {
+    background: none;
+    border: none;
+    cursor: pointer;
+    padding: 0;
+    text-decoration: underline;
+  }
+
 Miscellaneous
 -------------
 

+ 12 - 2
docs/topics/auth/default.txt

@@ -1160,7 +1160,12 @@ implementation details see :ref:`using-the-views`.
 
 .. class:: LogoutView
 
-    Logs a user out.
+    Logs a user out on ``POST`` requests.
+
+    .. deprecated:: 4.1
+
+        Support for logging out on ``GET`` requests is deprecated and will be
+        removed in Django 5.0.
 
     **URL name:** ``logout``
 
@@ -1212,7 +1217,7 @@ implementation details see :ref:`using-the-views`.
 
 .. function:: logout_then_login(request, login_url=None)
 
-    Logs a user out, then redirects to the login page.
+    Logs a user out on ``POST`` requests, then redirects to the login page.
 
     **URL name:** No default URL provided
 
@@ -1221,6 +1226,11 @@ implementation details see :ref:`using-the-views`.
     * ``login_url``: The URL of the login page to redirect to.
       Defaults to :setting:`settings.LOGIN_URL <LOGIN_URL>` if not supplied.
 
+    .. deprecated:: 4.1
+
+        Support for logging out on ``GET`` requests is deprecated and will be
+        removed in Django 5.0.
+
 .. class:: PasswordChangeView
 
     **URL name:** ``password_change``

+ 2 - 2
tests/auth_tests/test_signals.py

@@ -60,13 +60,13 @@ class SignalTestCase(TestCase):
     def test_logout_anonymous(self):
         # The log_out function will still trigger the signal for anonymous
         # users.
-        self.client.get("/logout/next_page/")
+        self.client.post("/logout/next_page/")
         self.assertEqual(len(self.logged_out), 1)
         self.assertIsNone(self.logged_out[0])
 
     def test_logout(self):
         self.client.login(username="testclient", password="password")
-        self.client.get("/logout/next_page/")
+        self.client.post("/logout/next_page/")
         self.assertEqual(len(self.logged_out), 1)
         self.assertEqual(self.logged_out[0].username, "testclient")
 

+ 53 - 22
tests/auth_tests/test_views.py

@@ -29,9 +29,10 @@ from django.core.exceptions import ImproperlyConfigured
 from django.db import connection
 from django.http import HttpRequest, HttpResponse
 from django.middleware.csrf import CsrfViewMiddleware, get_token
-from django.test import Client, TestCase, override_settings
+from django.test import Client, TestCase, ignore_warnings, override_settings
 from django.test.client import RedirectCycleError
 from django.urls import NoReverseMatch, reverse, reverse_lazy
+from django.utils.deprecation import RemovedInDjango50Warning
 from django.utils.http import urlsafe_base64_encode
 
 from .client import PasswordResetConfirmClient
@@ -538,7 +539,7 @@ class ChangePasswordTest(AuthViewsTestCase):
         )
 
     def logout(self):
-        self.client.get("/logout/")
+        self.client.post("/logout/")
 
     def test_password_change_fails_with_invalid_old_password(self):
         self.login()
@@ -979,7 +980,10 @@ class LogoutThenLoginTests(AuthViewsTestCase):
     def test_default_logout_then_login(self):
         self.login()
         req = HttpRequest()
-        req.method = "GET"
+        req.method = "POST"
+        csrf_token = get_token(req)
+        req.COOKIES[settings.CSRF_COOKIE_NAME] = csrf_token
+        req.POST = {"csrfmiddlewaretoken": csrf_token}
         req.session = self.client.session
         response = logout_then_login(req)
         self.confirm_logged_out()
@@ -988,12 +992,28 @@ class LogoutThenLoginTests(AuthViewsTestCase):
     def test_logout_then_login_with_custom_login(self):
         self.login()
         req = HttpRequest()
-        req.method = "GET"
+        req.method = "POST"
+        csrf_token = get_token(req)
+        req.COOKIES[settings.CSRF_COOKIE_NAME] = csrf_token
+        req.POST = {"csrfmiddlewaretoken": csrf_token}
         req.session = self.client.session
         response = logout_then_login(req, login_url="/custom/")
         self.confirm_logged_out()
         self.assertRedirects(response, "/custom/", fetch_redirect_response=False)
 
+    @ignore_warnings(category=RemovedInDjango50Warning)
+    @override_settings(LOGIN_URL="/login/")
+    def test_default_logout_then_login_get(self):
+        self.login()
+        req = HttpRequest()
+        req.method = "GET"
+        req.session = self.client.session
+        response = logout_then_login(req)
+        # RemovedInDjango50Warning: When the deprecation ends, replace with
+        #   self.assertEqual(response.status_code, 405)
+        self.confirm_logged_out()
+        self.assertRedirects(response, "/login/", fetch_redirect_response=False)
+
 
 class LoginRedirectAuthenticatedUser(AuthViewsTestCase):
     dont_redirect_url = "/login/redirect_authenticated_user_default/"
@@ -1136,7 +1156,7 @@ class LogoutTest(AuthViewsTestCase):
     def test_logout_default(self):
         "Logout without next_page option renders the default template"
         self.login()
-        response = self.client.get("/logout/")
+        response = self.client.post("/logout/")
         self.assertContains(response, "Logged out")
         self.confirm_logged_out()
 
@@ -1146,10 +1166,21 @@ class LogoutTest(AuthViewsTestCase):
         self.assertContains(response, "Logged out")
         self.confirm_logged_out()
 
+    def test_logout_with_get_raises_deprecation_warning(self):
+        self.login()
+        msg = (
+            "Log out via GET requests is deprecated and will be removed in Django 5.0. "
+            "Use POST requests for logging out."
+        )
+        with self.assertWarnsMessage(RemovedInDjango50Warning, msg):
+            response = self.client.get("/logout/")
+        self.assertContains(response, "Logged out")
+        self.confirm_logged_out()
+
     def test_14377(self):
         # Bug 14377
         self.login()
-        response = self.client.get("/logout/")
+        response = self.client.post("/logout/")
         self.assertIn("site", response.context)
 
     def test_logout_doesnt_cache(self):
@@ -1157,16 +1188,16 @@ class LogoutTest(AuthViewsTestCase):
         The logout() view should send "no-cache" headers for reasons described
         in #25490.
         """
-        response = self.client.get("/logout/")
+        response = self.client.post("/logout/")
         self.assertIn("no-store", response.headers["Cache-Control"])
 
     def test_logout_with_overridden_redirect_url(self):
         # Bug 11223
         self.login()
-        response = self.client.get("/logout/next_page/")
+        response = self.client.post("/logout/next_page/")
         self.assertRedirects(response, "/somewhere/", fetch_redirect_response=False)
 
-        response = self.client.get("/logout/next_page/?next=/login/")
+        response = self.client.post("/logout/next_page/?next=/login/")
         self.assertRedirects(response, "/login/", fetch_redirect_response=False)
 
         self.confirm_logged_out()
@@ -1174,28 +1205,28 @@ class LogoutTest(AuthViewsTestCase):
     def test_logout_with_next_page_specified(self):
         "Logout with next_page option given redirects to specified resource"
         self.login()
-        response = self.client.get("/logout/next_page/")
+        response = self.client.post("/logout/next_page/")
         self.assertRedirects(response, "/somewhere/", fetch_redirect_response=False)
         self.confirm_logged_out()
 
     def test_logout_with_redirect_argument(self):
         "Logout with query string redirects to specified resource"
         self.login()
-        response = self.client.get("/logout/?next=/login/")
+        response = self.client.post("/logout/?next=/login/")
         self.assertRedirects(response, "/login/", fetch_redirect_response=False)
         self.confirm_logged_out()
 
     def test_logout_with_custom_redirect_argument(self):
         "Logout with custom query string redirects to specified resource"
         self.login()
-        response = self.client.get("/logout/custom_query/?follow=/somewhere/")
+        response = self.client.post("/logout/custom_query/?follow=/somewhere/")
         self.assertRedirects(response, "/somewhere/", fetch_redirect_response=False)
         self.confirm_logged_out()
 
     def test_logout_with_named_redirect(self):
         "Logout resolves names or URLs passed as next_page."
         self.login()
-        response = self.client.get("/logout/next_page/named/")
+        response = self.client.post("/logout/next_page/named/")
         self.assertRedirects(
             response, "/password_reset/", fetch_redirect_response=False
         )
@@ -1203,7 +1234,7 @@ class LogoutTest(AuthViewsTestCase):
 
     def test_success_url_allowed_hosts_same_host(self):
         self.login()
-        response = self.client.get("/logout/allowed_hosts/?next=https://testserver/")
+        response = self.client.post("/logout/allowed_hosts/?next=https://testserver/")
         self.assertRedirects(
             response, "https://testserver/", fetch_redirect_response=False
         )
@@ -1211,7 +1242,7 @@ class LogoutTest(AuthViewsTestCase):
 
     def test_success_url_allowed_hosts_safe_host(self):
         self.login()
-        response = self.client.get("/logout/allowed_hosts/?next=https://otherserver/")
+        response = self.client.post("/logout/allowed_hosts/?next=https://otherserver/")
         self.assertRedirects(
             response, "https://otherserver/", fetch_redirect_response=False
         )
@@ -1219,7 +1250,7 @@ class LogoutTest(AuthViewsTestCase):
 
     def test_success_url_allowed_hosts_unsafe_host(self):
         self.login()
-        response = self.client.get("/logout/allowed_hosts/?next=https://evil/")
+        response = self.client.post("/logout/allowed_hosts/?next=https://evil/")
         self.assertRedirects(
             response, "/logout/allowed_hosts/", fetch_redirect_response=False
         )
@@ -1246,7 +1277,7 @@ class LogoutTest(AuthViewsTestCase):
                     "bad_url": quote(bad_url),
                 }
                 self.login()
-                response = self.client.get(nasty_url)
+                response = self.client.post(nasty_url)
                 self.assertEqual(response.status_code, 302)
                 self.assertNotIn(
                     bad_url, response.url, "%s should be blocked" % bad_url
@@ -1272,7 +1303,7 @@ class LogoutTest(AuthViewsTestCase):
                     "good_url": quote(good_url),
                 }
                 self.login()
-                response = self.client.get(safe_url)
+                response = self.client.post(safe_url)
                 self.assertEqual(response.status_code, 302)
                 self.assertIn(good_url, response.url, "%s should be allowed" % good_url)
                 self.confirm_logged_out()
@@ -1286,7 +1317,7 @@ class LogoutTest(AuthViewsTestCase):
             "next_url": quote(non_https_next_url),
         }
         self.login()
-        response = self.client.get(url, secure=True)
+        response = self.client.post(url, secure=True)
         self.assertRedirects(response, logout_url, fetch_redirect_response=False)
         self.confirm_logged_out()
 
@@ -1295,19 +1326,19 @@ class LogoutTest(AuthViewsTestCase):
         self.login()
         self.client.post("/setlang/", {"language": "pl"})
         self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, "pl")
-        self.client.get("/logout/")
+        self.client.post("/logout/")
         self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, "pl")
 
     @override_settings(LOGOUT_REDIRECT_URL="/custom/")
     def test_logout_redirect_url_setting(self):
         self.login()
-        response = self.client.get("/logout/")
+        response = self.client.post("/logout/")
         self.assertRedirects(response, "/custom/", fetch_redirect_response=False)
 
     @override_settings(LOGOUT_REDIRECT_URL="logout")
     def test_logout_redirect_url_named_setting(self):
         self.login()
-        response = self.client.get("/logout/")
+        response = self.client.post("/logout/")
         self.assertRedirects(response, "/logout/", fetch_redirect_response=False)