Skip to content

Commit eb07b5b

Browse files
Fixed #15619 -- Deprecated log out via GET requests.
Thanks Florian Apolloner for the implementation idea. Co-Authored-By: Mariusz Felisiak <[email protected]>
1 parent d4bf3b4 commit eb07b5b

File tree

6 files changed

+122
-30
lines changed

6 files changed

+122
-30
lines changed

django/contrib/auth/views.py

+21-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import warnings
12
from urllib.parse import urlparse, urlunparse
23

34
from django.conf import settings
@@ -21,6 +22,7 @@
2122
from django.shortcuts import resolve_url
2223
from django.urls import reverse_lazy
2324
from django.utils.decorators import method_decorator
25+
from django.utils.deprecation import RemovedInDjango50Warning
2426
from django.utils.http import url_has_allowed_host_and_scheme, urlsafe_base64_decode
2527
from django.utils.translation import gettext_lazy as _
2628
from django.views.decorators.cache import never_cache
@@ -117,23 +119,38 @@ class LogoutView(SuccessURLAllowedHostsMixin, TemplateView):
117119
Log out the user and display the 'You are logged out' message.
118120
"""
119121

122+
# RemovedInDjango50Warning: when the deprecation ends, remove "get" and
123+
# "head" from http_method_names.
124+
http_method_names = ["get", "head", "post", "options"]
120125
next_page = None
121126
redirect_field_name = REDIRECT_FIELD_NAME
122127
template_name = "registration/logged_out.html"
123128
extra_context = None
124129

130+
# RemovedInDjango50Warning: when the deprecation ends, move
131+
# @method_decorator(csrf_protect) from post() to dispatch().
125132
@method_decorator(never_cache)
126133
def dispatch(self, request, *args, **kwargs):
134+
if request.method.lower() == "get":
135+
warnings.warn(
136+
"Log out via GET requests is deprecated and will be removed in Django "
137+
"5.0. Use POST requests for logging out.",
138+
RemovedInDjango50Warning,
139+
)
140+
return super().dispatch(request, *args, **kwargs)
141+
142+
@method_decorator(csrf_protect)
143+
def post(self, request, *args, **kwargs):
144+
"""Logout may be done via POST."""
127145
auth_logout(request)
128146
next_page = self.get_next_page()
129147
if next_page:
130148
# Redirect to this page until the session has been cleared.
131149
return HttpResponseRedirect(next_page)
132-
return super().dispatch(request, *args, **kwargs)
150+
return super().get(request, *args, **kwargs)
133151

134-
def post(self, request, *args, **kwargs):
135-
"""Logout may be done via POST."""
136-
return self.get(request, *args, **kwargs)
152+
# RemovedInDjango50Warning.
153+
get = post
137154

138155
def get_next_page(self):
139156
if self.next_page is not None:

docs/internals/deprecation.txt

+4
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ details on these changes.
9090
* ``created=True`` will be required in the signature of
9191
``RemoteUserBackend.configure_user()`` subclasses.
9292

93+
* Support for logging out via ``GET`` requests in the
94+
``django.contrib.auth.views.LogoutView`` and
95+
``django.contrib.auth.views.logout_then_login()`` will be removed.
96+
9397
.. _deprecation-removed-in-4.1:
9498

9599
4.1

docs/releases/4.1.txt

+30
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,36 @@ Miscellaneous
446446
Features deprecated in 4.1
447447
==========================
448448

449+
Log out via GET
450+
---------------
451+
452+
Logging out via ``GET`` requests to the :py:class:`built-in logout view
453+
<django.contrib.auth.views.LogoutView>` is deprecated. Use ``POST`` requests
454+
instead.
455+
456+
If you want to retain the user experience of an HTML link, you can use a form
457+
that is styled to appear as a link:
458+
459+
.. code-block:: html
460+
461+
<form id="logout-form" method="post" action="{% url 'admin:logout' %}">
462+
{% csrf_token %}
463+
<button type="submit">{% translate "Log out" %}</button>
464+
</form>
465+
466+
.. code-block:: css
467+
468+
#logout-form {
469+
display: inline;
470+
}
471+
#logout-form button {
472+
background: none;
473+
border: none;
474+
cursor: pointer;
475+
padding: 0;
476+
text-decoration: underline;
477+
}
478+
449479
Miscellaneous
450480
-------------
451481

docs/topics/auth/default.txt

+12-2
Original file line numberDiff line numberDiff line change
@@ -1160,7 +1160,12 @@ implementation details see :ref:`using-the-views`.
11601160

11611161
.. class:: LogoutView
11621162

1163-
Logs a user out.
1163+
Logs a user out on ``POST`` requests.
1164+
1165+
.. deprecated:: 4.1
1166+
1167+
Support for logging out on ``GET`` requests is deprecated and will be
1168+
removed in Django 5.0.
11641169

11651170
**URL name:** ``logout``
11661171

@@ -1212,7 +1217,7 @@ implementation details see :ref:`using-the-views`.
12121217

12131218
.. function:: logout_then_login(request, login_url=None)
12141219

1215-
Logs a user out, then redirects to the login page.
1220+
Logs a user out on ``POST`` requests, then redirects to the login page.
12161221

12171222
**URL name:** No default URL provided
12181223

@@ -1221,6 +1226,11 @@ implementation details see :ref:`using-the-views`.
12211226
* ``login_url``: The URL of the login page to redirect to.
12221227
Defaults to :setting:`settings.LOGIN_URL <LOGIN_URL>` if not supplied.
12231228

1229+
.. deprecated:: 4.1
1230+
1231+
Support for logging out on ``GET`` requests is deprecated and will be
1232+
removed in Django 5.0.
1233+
12241234
.. class:: PasswordChangeView
12251235

12261236
**URL name:** ``password_change``

tests/auth_tests/test_signals.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,13 @@ def test_login(self):
6060
def test_logout_anonymous(self):
6161
# The log_out function will still trigger the signal for anonymous
6262
# users.
63-
self.client.get("/logout/next_page/")
63+
self.client.post("/logout/next_page/")
6464
self.assertEqual(len(self.logged_out), 1)
6565
self.assertIsNone(self.logged_out[0])
6666

6767
def test_logout(self):
6868
self.client.login(username="testclient", password="password")
69-
self.client.get("/logout/next_page/")
69+
self.client.post("/logout/next_page/")
7070
self.assertEqual(len(self.logged_out), 1)
7171
self.assertEqual(self.logged_out[0].username, "testclient")
7272

tests/auth_tests/test_views.py

+53-22
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@
2929
from django.db import connection
3030
from django.http import HttpRequest, HttpResponse
3131
from django.middleware.csrf import CsrfViewMiddleware, get_token
32-
from django.test import Client, TestCase, override_settings
32+
from django.test import Client, TestCase, ignore_warnings, override_settings
3333
from django.test.client import RedirectCycleError
3434
from django.urls import NoReverseMatch, reverse, reverse_lazy
35+
from django.utils.deprecation import RemovedInDjango50Warning
3536
from django.utils.http import urlsafe_base64_encode
3637

3738
from .client import PasswordResetConfirmClient
@@ -538,7 +539,7 @@ def fail_login(self):
538539
)
539540

540541
def logout(self):
541-
self.client.get("/logout/")
542+
self.client.post("/logout/")
542543

543544
def test_password_change_fails_with_invalid_old_password(self):
544545
self.login()
@@ -979,7 +980,10 @@ def confirm_logged_out(self):
979980
def test_default_logout_then_login(self):
980981
self.login()
981982
req = HttpRequest()
982-
req.method = "GET"
983+
req.method = "POST"
984+
csrf_token = get_token(req)
985+
req.COOKIES[settings.CSRF_COOKIE_NAME] = csrf_token
986+
req.POST = {"csrfmiddlewaretoken": csrf_token}
983987
req.session = self.client.session
984988
response = logout_then_login(req)
985989
self.confirm_logged_out()
@@ -988,12 +992,28 @@ def test_default_logout_then_login(self):
988992
def test_logout_then_login_with_custom_login(self):
989993
self.login()
990994
req = HttpRequest()
991-
req.method = "GET"
995+
req.method = "POST"
996+
csrf_token = get_token(req)
997+
req.COOKIES[settings.CSRF_COOKIE_NAME] = csrf_token
998+
req.POST = {"csrfmiddlewaretoken": csrf_token}
992999
req.session = self.client.session
9931000
response = logout_then_login(req, login_url="/custom/")
9941001
self.confirm_logged_out()
9951002
self.assertRedirects(response, "/custom/", fetch_redirect_response=False)
9961003

1004+
@ignore_warnings(category=RemovedInDjango50Warning)
1005+
@override_settings(LOGIN_URL="/login/")
1006+
def test_default_logout_then_login_get(self):
1007+
self.login()
1008+
req = HttpRequest()
1009+
req.method = "GET"
1010+
req.session = self.client.session
1011+
response = logout_then_login(req)
1012+
# RemovedInDjango50Warning: When the deprecation ends, replace with
1013+
# self.assertEqual(response.status_code, 405)
1014+
self.confirm_logged_out()
1015+
self.assertRedirects(response, "/login/", fetch_redirect_response=False)
1016+
9971017

9981018
class LoginRedirectAuthenticatedUser(AuthViewsTestCase):
9991019
dont_redirect_url = "/login/redirect_authenticated_user_default/"
@@ -1136,7 +1156,7 @@ def confirm_logged_out(self):
11361156
def test_logout_default(self):
11371157
"Logout without next_page option renders the default template"
11381158
self.login()
1139-
response = self.client.get("/logout/")
1159+
response = self.client.post("/logout/")
11401160
self.assertContains(response, "Logged out")
11411161
self.confirm_logged_out()
11421162

@@ -1146,80 +1166,91 @@ def test_logout_with_post(self):
11461166
self.assertContains(response, "Logged out")
11471167
self.confirm_logged_out()
11481168

1169+
def test_logout_with_get_raises_deprecation_warning(self):
1170+
self.login()
1171+
msg = (
1172+
"Log out via GET requests is deprecated and will be removed in Django 5.0. "
1173+
"Use POST requests for logging out."
1174+
)
1175+
with self.assertWarnsMessage(RemovedInDjango50Warning, msg):
1176+
response = self.client.get("/logout/")
1177+
self.assertContains(response, "Logged out")
1178+
self.confirm_logged_out()
1179+
11491180
def test_14377(self):
11501181
# Bug 14377
11511182
self.login()
1152-
response = self.client.get("/logout/")
1183+
response = self.client.post("/logout/")
11531184
self.assertIn("site", response.context)
11541185

11551186
def test_logout_doesnt_cache(self):
11561187
"""
11571188
The logout() view should send "no-cache" headers for reasons described
11581189
in #25490.
11591190
"""
1160-
response = self.client.get("/logout/")
1191+
response = self.client.post("/logout/")
11611192
self.assertIn("no-store", response.headers["Cache-Control"])
11621193

11631194
def test_logout_with_overridden_redirect_url(self):
11641195
# Bug 11223
11651196
self.login()
1166-
response = self.client.get("/logout/next_page/")
1197+
response = self.client.post("/logout/next_page/")
11671198
self.assertRedirects(response, "/somewhere/", fetch_redirect_response=False)
11681199

1169-
response = self.client.get("/logout/next_page/?next=/login/")
1200+
response = self.client.post("/logout/next_page/?next=/login/")
11701201
self.assertRedirects(response, "/login/", fetch_redirect_response=False)
11711202

11721203
self.confirm_logged_out()
11731204

11741205
def test_logout_with_next_page_specified(self):
11751206
"Logout with next_page option given redirects to specified resource"
11761207
self.login()
1177-
response = self.client.get("/logout/next_page/")
1208+
response = self.client.post("/logout/next_page/")
11781209
self.assertRedirects(response, "/somewhere/", fetch_redirect_response=False)
11791210
self.confirm_logged_out()
11801211

11811212
def test_logout_with_redirect_argument(self):
11821213
"Logout with query string redirects to specified resource"
11831214
self.login()
1184-
response = self.client.get("/logout/?next=/login/")
1215+
response = self.client.post("/logout/?next=/login/")
11851216
self.assertRedirects(response, "/login/", fetch_redirect_response=False)
11861217
self.confirm_logged_out()
11871218

11881219
def test_logout_with_custom_redirect_argument(self):
11891220
"Logout with custom query string redirects to specified resource"
11901221
self.login()
1191-
response = self.client.get("/logout/custom_query/?follow=/somewhere/")
1222+
response = self.client.post("/logout/custom_query/?follow=/somewhere/")
11921223
self.assertRedirects(response, "/somewhere/", fetch_redirect_response=False)
11931224
self.confirm_logged_out()
11941225

11951226
def test_logout_with_named_redirect(self):
11961227
"Logout resolves names or URLs passed as next_page."
11971228
self.login()
1198-
response = self.client.get("/logout/next_page/named/")
1229+
response = self.client.post("/logout/next_page/named/")
11991230
self.assertRedirects(
12001231
response, "/password_reset/", fetch_redirect_response=False
12011232
)
12021233
self.confirm_logged_out()
12031234

12041235
def test_success_url_allowed_hosts_same_host(self):
12051236
self.login()
1206-
response = self.client.get("/logout/allowed_hosts/?next=https://testserver/")
1237+
response = self.client.post("/logout/allowed_hosts/?next=https://testserver/")
12071238
self.assertRedirects(
12081239
response, "https://testserver/", fetch_redirect_response=False
12091240
)
12101241
self.confirm_logged_out()
12111242

12121243
def test_success_url_allowed_hosts_safe_host(self):
12131244
self.login()
1214-
response = self.client.get("/logout/allowed_hosts/?next=https://otherserver/")
1245+
response = self.client.post("/logout/allowed_hosts/?next=https://otherserver/")
12151246
self.assertRedirects(
12161247
response, "https://otherserver/", fetch_redirect_response=False
12171248
)
12181249
self.confirm_logged_out()
12191250

12201251
def test_success_url_allowed_hosts_unsafe_host(self):
12211252
self.login()
1222-
response = self.client.get("/logout/allowed_hosts/?next=https://evil/")
1253+
response = self.client.post("/logout/allowed_hosts/?next=https://evil/")
12231254
self.assertRedirects(
12241255
response, "/logout/allowed_hosts/", fetch_redirect_response=False
12251256
)
@@ -1246,7 +1277,7 @@ def test_security_check(self):
12461277
"bad_url": quote(bad_url),
12471278
}
12481279
self.login()
1249-
response = self.client.get(nasty_url)
1280+
response = self.client.post(nasty_url)
12501281
self.assertEqual(response.status_code, 302)
12511282
self.assertNotIn(
12521283
bad_url, response.url, "%s should be blocked" % bad_url
@@ -1272,7 +1303,7 @@ def test_security_check(self):
12721303
"good_url": quote(good_url),
12731304
}
12741305
self.login()
1275-
response = self.client.get(safe_url)
1306+
response = self.client.post(safe_url)
12761307
self.assertEqual(response.status_code, 302)
12771308
self.assertIn(good_url, response.url, "%s should be allowed" % good_url)
12781309
self.confirm_logged_out()
@@ -1286,7 +1317,7 @@ def test_security_check_https(self):
12861317
"next_url": quote(non_https_next_url),
12871318
}
12881319
self.login()
1289-
response = self.client.get(url, secure=True)
1320+
response = self.client.post(url, secure=True)
12901321
self.assertRedirects(response, logout_url, fetch_redirect_response=False)
12911322
self.confirm_logged_out()
12921323

@@ -1295,19 +1326,19 @@ def test_logout_preserve_language(self):
12951326
self.login()
12961327
self.client.post("/setlang/", {"language": "pl"})
12971328
self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, "pl")
1298-
self.client.get("/logout/")
1329+
self.client.post("/logout/")
12991330
self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, "pl")
13001331

13011332
@override_settings(LOGOUT_REDIRECT_URL="/custom/")
13021333
def test_logout_redirect_url_setting(self):
13031334
self.login()
1304-
response = self.client.get("/logout/")
1335+
response = self.client.post("/logout/")
13051336
self.assertRedirects(response, "/custom/", fetch_redirect_response=False)
13061337

13071338
@override_settings(LOGOUT_REDIRECT_URL="logout")
13081339
def test_logout_redirect_url_named_setting(self):
13091340
self.login()
1310-
response = self.client.get("/logout/")
1341+
response = self.client.post("/logout/")
13111342
self.assertRedirects(response, "/logout/", fetch_redirect_response=False)
13121343

13131344

0 commit comments

Comments
 (0)