Code

Ticket #11025: 11025.diff

File 11025.diff, 14.9 KB (added by SmileyChris, 4 years ago)
Line 
1diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py
2index cd85ce0..e4b0c92 100644
3--- a/django/conf/global_settings.py
4+++ b/django/conf/global_settings.py
5@@ -473,6 +473,8 @@ LOGOUT_URL = '/accounts/logout/'
6 
7 LOGIN_REDIRECT_URL = '/accounts/profile/'
8 
9+LOGIN_URL_NEXT_ARG = 'next'
10+
11 # The number of days a password reset link is valid for
12 PASSWORD_RESET_TIMEOUT_DAYS = 3
13 
14diff --git a/django/contrib/auth/__init__.py b/django/contrib/auth/__init__.py
15index a184aea..861bad6 100644
16--- a/django/contrib/auth/__init__.py
17+++ b/django/contrib/auth/__init__.py
18@@ -1,11 +1,12 @@
19 import datetime
20 from warnings import warn
21+from django.conf import settings
22 from django.core.exceptions import ImproperlyConfigured
23 from django.utils.importlib import import_module
24 
25 SESSION_KEY = '_auth_user_id'
26 BACKEND_SESSION_KEY = '_auth_user_backend'
27-REDIRECT_FIELD_NAME = 'next'
28+REDIRECT_FIELD_NAME = settings.LOGIN_URL_NEXT_ARG
29 
30 def load_backend(path):
31     i = path.rfind('.')
32@@ -32,7 +33,6 @@ def load_backend(path):
33     return cls()
34 
35 def get_backends():
36-    from django.conf import settings
37     backends = []
38     for backend_path in settings.AUTHENTICATION_BACKENDS:
39         backends.append(load_backend(backend_path))
40diff --git a/django/contrib/auth/decorators.py b/django/contrib/auth/decorators.py
41index 7d7a0cd..64b77a5 100644
42--- a/django/contrib/auth/decorators.py
43+++ b/django/contrib/auth/decorators.py
44@@ -1,12 +1,12 @@
45+import urlparse
46 try:
47-    from functools import update_wrapper, wraps
48+    from functools import wraps
49 except ImportError:
50-    from django.utils.functional import update_wrapper, wraps  # Python 2.4 fallback.
51+    from django.utils.functional import wraps  # Python 2.4 fallback.
52 
53+from django.conf import settings
54 from django.contrib.auth import REDIRECT_FIELD_NAME
55-from django.http import HttpResponseRedirect
56 from django.utils.decorators import available_attrs
57-from django.utils.http import urlquote
58 
59 
60 def user_passes_test(test_func, login_url=None, redirect_field_name=REDIRECT_FIELD_NAME):
61@@ -15,18 +15,24 @@ def user_passes_test(test_func, login_url=None, redirect_field_name=REDIRECT_FIE
62     redirecting to the log-in page if necessary. The test should be a callable
63     that takes the user object and returns True if the user passes.
64     """
65-    if not login_url:
66-        from django.conf import settings
67-        login_url = settings.LOGIN_URL
68 
69     def decorator(view_func):
70+        @wraps(view_func, assigned=available_attrs(view_func))
71         def _wrapped_view(request, *args, **kwargs):
72             if test_func(request.user):
73                 return view_func(request, *args, **kwargs)
74-            path = urlquote(request.get_full_path())
75-            tup = login_url, redirect_field_name, path
76-            return HttpResponseRedirect('%s?%s=%s' % tup)
77-        return wraps(view_func, assigned=available_attrs(view_func))(_wrapped_view)
78+            path = request.build_absolute_uri()
79+            # If the login url is the same scheme and net location then just
80+            # use the path as the "next" url.
81+            login_scheme, login_netloc = urlparse.urlparse(login_url or
82+                                                        settings.LOGIN_URL)[:2]
83+            current_scheme, current_netloc = urlparse.urlparse(path)[:2]
84+            if ((not login_scheme or login_scheme == current_scheme) and
85+                (not login_netloc or login_netloc == current_netloc)):
86+                path = request.get_full_path()
87+            from django.contrib.auth.views import redirect_to_login
88+            return redirect_to_login(path, login_url, redirect_field_name)
89+        return _wrapped_view
90     return decorator
91 
92 
93diff --git a/django/contrib/auth/tests/__init__.py b/django/contrib/auth/tests/__init__.py
94index a1d02b6..b969b52 100644
95--- a/django/contrib/auth/tests/__init__.py
96+++ b/django/contrib/auth/tests/__init__.py
97@@ -6,8 +6,8 @@ from django.contrib.auth.tests.remote_user \
98         import RemoteUserTest, RemoteUserNoCreateTest, RemoteUserCustomTest
99 from django.contrib.auth.tests.models import ProfileTestCase
100 from django.contrib.auth.tests.tokens import TOKEN_GENERATOR_TESTS
101-from django.contrib.auth.tests.views \
102-        import PasswordResetTest, ChangePasswordTest, LoginTest, LogoutTest
103+from django.contrib.auth.tests.views import PasswordResetTest, \
104+    ChangePasswordTest, LoginTest, LogoutTest, LoginURLSettings
105 
106 # The password for the fixture data users is 'password'
107 
108diff --git a/django/contrib/auth/tests/decorators.py b/django/contrib/auth/tests/decorators.py
109index 0240a76..e58bbfa 100644
110--- a/django/contrib/auth/tests/decorators.py
111+++ b/django/contrib/auth/tests/decorators.py
112@@ -42,4 +42,4 @@ class LoginRequiredTestCase(AuthViewsTestCase):
113         login_required decorator with a login_url set.
114         """
115         self.testLoginRequired(view_url='/login_required_login_url/',
116-            login_url='/somewhere/')
117\ No newline at end of file
118+            login_url='/somewhere/')
119diff --git a/django/contrib/auth/tests/views.py b/django/contrib/auth/tests/views.py
120index 42f7f12..681d2cc 100644
121--- a/django/contrib/auth/tests/views.py
122+++ b/django/contrib/auth/tests/views.py
123@@ -5,11 +5,12 @@ import urllib
124 from django.conf import settings
125 from django.contrib.auth import SESSION_KEY, REDIRECT_FIELD_NAME
126 from django.contrib.auth.forms import AuthenticationForm
127-from django.contrib.sites.models import Site, RequestSite
128+from django.contrib.sites.models import Site
129 from django.contrib.auth.models import User
130 from django.test import TestCase
131 from django.core import mail
132 from django.core.urlresolvers import reverse
133+from django.http import QueryDict
134 
135 class AuthViewsTestCase(TestCase):
136     """
137@@ -25,16 +26,16 @@ class AuthViewsTestCase(TestCase):
138         settings.LANGUAGE_CODE = 'en'
139         self.old_TEMPLATE_DIRS = settings.TEMPLATE_DIRS
140         settings.TEMPLATE_DIRS = (
141-            os.path.join(
142-                os.path.dirname(__file__),
143-                'templates'
144-            )
145-        ,)
146+            os.path.join(os.path.dirname(__file__), 'templates'),
147+        )
148+        self.old_LOGIN_URL_NEXT_ARG = settings.LOGIN_URL_NEXT_ARG
149+        settings.LOGIN_URL_NEXT_ARG = 'next'
150 
151     def tearDown(self):
152         settings.LANGUAGES = self.old_LANGUAGES
153         settings.LANGUAGE_CODE = self.old_LANGUAGE_CODE
154         settings.TEMPLATE_DIRS = self.old_TEMPLATE_DIRS
155+        settings.LOGIN_URL_NEXT_ARG = self.old_LOGIN_URL_NEXT_ARG
156 
157     def login(self, password='password'):
158         response = self.client.post('/login/', {
159@@ -214,16 +215,20 @@ class LoginTest(AuthViewsTestCase):
160                 }
161             )
162             self.assertEquals(response.status_code, 302)
163-            self.assertFalse(bad_url in response['Location'], "%s should be blocked" % bad_url)
164-
165-        # Now, these URLs have an other URL as a GET parameter and therefore
166-        # should be allowed
167-        for url_ in ('http://example.com', 'https://example.com',
168-                    'ftp://exampel.com',  '//example.com'):
169-            safe_url = '%(url)s?%(next)s=/view/?param=%(safe_param)s' % {
170+            self.assertFalse(bad_url in response['Location'],
171+                             "%s should be blocked" % bad_url)
172+
173+        # These URLs *should* still pass the security check
174+        for good_url in ('/view/?param=http://example.com',
175+                         '/view/?param=https://example.com',
176+                         '/view?param=ftp://exampel.com',
177+                         'view/?param=//example.com',
178+                         'https:///',
179+                         '//testserver/'):
180+            safe_url = '%(url)s?%(next)s=%(good_url)s' % {
181                 'url': login_url,
182                 'next': REDIRECT_FIELD_NAME,
183-                'safe_param': urllib.quote(url_)
184+                'good_url': urllib.quote(good_url)
185             }
186             response = self.client.post(safe_url, {
187                     'username': 'testclient',
188@@ -231,8 +236,66 @@ class LoginTest(AuthViewsTestCase):
189                 }
190             )
191             self.assertEquals(response.status_code, 302)
192-            self.assertTrue('/view/?param=%s' % url_ in response['Location'], "/view/?param=%s should be allowed" % url_)
193+            self.assertTrue(good_url in response['Location'],
194+                            "%s should be allowed" % good_url)
195 
196+class LoginURLSettings(AuthViewsTestCase):
197+    urls = 'django.contrib.auth.tests.urls'
198+   
199+    def setUp(self):
200+        super(LoginURLSettings, self).setUp()
201+        self.old_LOGIN_URL = settings.LOGIN_URL
202+
203+    def tearDown(self):
204+        super(LoginURLSettings, self).tearDown()
205+        settings.LOGIN_URL = self.old_LOGIN_URL
206+
207+    def get_login_required_url(self, login_url):
208+        settings.LOGIN_URL = login_url
209+        response = self.client.get('/login_required/')
210+        self.assertEquals(response.status_code, 302)
211+        return response['Location']
212+
213+    def test_standard_login_url(self):
214+        login_url = '/login/'
215+        login_required_url = self.get_login_required_url(login_url)
216+        querystring = QueryDict('', mutable=True)
217+        querystring['next'] = '/login_required/'
218+        self.assertEqual(login_required_url,
219+             'http://testserver%s?%s' % (login_url, querystring.urlencode()))
220+
221+    def test_remote_login_url(self):
222+        login_url = 'http://remote.example.com/login'
223+        login_required_url = self.get_login_required_url(login_url)
224+        querystring = QueryDict('', mutable=True)
225+        querystring['next'] = 'http://testserver/login_required/'
226+        self.assertEqual(login_required_url,
227+                         '%s?%s' % (login_url, querystring.urlencode()))
228+
229+    def test_https_login_url(self):
230+        login_url = 'https:///login/'
231+        login_required_url = self.get_login_required_url(login_url)
232+        querystring = QueryDict('', mutable=True)
233+        querystring['next'] = 'http://testserver/login_required/'
234+        self.assertEqual(login_required_url,
235+                         '%s?%s' % (login_url, querystring.urlencode()))
236+
237+    def test_login_url_with_querystring(self):
238+        login_url = '/login/?pretty=1'
239+        login_required_url = self.get_login_required_url(login_url)
240+        querystring = QueryDict('pretty=1', mutable=True)
241+        querystring['next'] = '/login_required/'
242+        self.assertEqual(login_required_url, 'http://testserver/login/?%s' %
243+                         querystring.urlencode())
244+
245+    def test_remote_login_url_with_next_querystring(self):
246+        login_url = 'http://remote.example.com/login/'
247+        login_required_url = self.get_login_required_url('%s?next=/default/' %
248+                                                         login_url)
249+        querystring = QueryDict('', mutable=True)
250+        querystring['next'] = 'http://testserver/login_required/'
251+        self.assertEqual(login_required_url, '%s?%s' % (login_url,
252+                                                    querystring.urlencode()))
253         
254 class LogoutTest(AuthViewsTestCase):
255     urls = 'django.contrib.auth.tests.urls'
256diff --git a/django/contrib/auth/views.py b/django/contrib/auth/views.py
257index a55c866..f55ca79 100644
258--- a/django/contrib/auth/views.py
259+++ b/django/contrib/auth/views.py
260@@ -1,4 +1,5 @@
261 import re
262+import urlparse
263 from django.conf import settings
264 from django.contrib.auth import REDIRECT_FIELD_NAME
265 # Avoid shadowing the login() view below.
266@@ -11,9 +12,9 @@ from django.views.decorators.csrf import csrf_protect
267 from django.core.urlresolvers import reverse
268 from django.shortcuts import render_to_response, get_object_or_404
269 from django.contrib.sites.models import get_current_site
270-from django.http import HttpResponseRedirect, Http404
271+from django.http import HttpResponseRedirect, Http404, QueryDict
272 from django.template import RequestContext
273-from django.utils.http import urlquote, base36_to_int
274+from django.utils.http import base36_to_int
275 from django.utils.translation import ugettext as _
276 from django.contrib.auth.models import User
277 from django.views.decorators.cache import never_cache
278@@ -30,16 +31,16 @@ def login(request, template_name='registration/login.html',
279     if request.method == "POST":
280         form = authentication_form(data=request.POST)
281         if form.is_valid():
282+            netloc = urlparse.urlparse(redirect_to)[1]
283+
284             # Light security check -- make sure redirect_to isn't garbage.
285             if not redirect_to or ' ' in redirect_to:
286                 redirect_to = settings.LOGIN_REDIRECT_URL
287 
288-            # Heavier security check -- redirects to http://example.com should
289-            # not be allowed, but things like /view/?param=http://example.com
290-            # should be allowed. This regex checks if there is a '//' *before* a
291-            # question mark.
292-            elif '//' in redirect_to and re.match(r'[^\?]*//', redirect_to):
293-                    redirect_to = settings.LOGIN_REDIRECT_URL
294+            # Heavier security check -- don't allow redirection to a different
295+            # host.
296+            elif netloc and netloc != request.get_host():
297+                redirect_to = settings.LOGIN_REDIRECT_URL
298 
299             # Okay, security checks complete. Log the user in.
300             auth_login(request, form.get_user())
301@@ -88,11 +89,19 @@ def logout_then_login(request, login_url=None):
302         login_url = settings.LOGIN_URL
303     return logout(request, login_url)
304 
305-def redirect_to_login(next, login_url=None, redirect_field_name=REDIRECT_FIELD_NAME):
306+def redirect_to_login(next, login_url=None,
307+                      redirect_field_name=REDIRECT_FIELD_NAME):
308     "Redirects the user to the login page, passing the given 'next' page"
309     if not login_url:
310         login_url = settings.LOGIN_URL
311-    return HttpResponseRedirect('%s?%s=%s' % (login_url, urlquote(redirect_field_name), urlquote(next)))
312+
313+    login_url_parts = list(urlparse.urlparse(login_url))
314+    if redirect_field_name:
315+        querystring = QueryDict(login_url_parts[4], mutable=True)
316+        querystring[redirect_field_name] = next
317+        login_url_parts[4] = querystring.urlencode()
318+
319+    return HttpResponseRedirect(urlparse.urlunparse(login_url_parts))
320 
321 # 4 views for password reset:
322 # - password_reset sends the mail
323diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt
324index c72b171..f4ebed2 100644
325--- a/docs/ref/settings.txt
326+++ b/docs/ref/settings.txt
327@@ -1067,6 +1067,21 @@ Default: ``'/accounts/login/'``
328 The URL where requests are redirected for login, especially when using the
329 :func:`~django.contrib.auth.decorators.login_required` decorator.
330 
331+.. setting:: LOGIN_URL_NEXT_ARG
332+
333+LOGIN_URL_NEXT_ARG
334+------------------
335+
336+.. versionadded:: 1.3
337+
338+Default: ``'next'``
339+
340+The argument used when building the "next url" querystring for the login URL.
341+For example, with the default settings an anonymous user accessing a URL of
342+``/secure_page/`` which maps to a view decorated with
343+:func:`~django.contrib.auth.decorators.login_required` will be redirected to:
344+``/accounts/login/?next=/secure_page/``.
345+
346 .. setting:: LOGOUT_URL
347 
348 LOGOUT_URL