diff --git a/django/contrib/auth/decorators.py b/django/contrib/auth/decorators.py index 7d7a0cddb7..64b77a5f96 100644 --- a/django/contrib/auth/decorators.py +++ b/django/contrib/auth/decorators.py @@ -1,12 +1,12 @@ +import urlparse try: - from functools import update_wrapper, wraps + from functools import wraps except ImportError: - from django.utils.functional import update_wrapper, wraps # Python 2.4 fallback. + from django.utils.functional import wraps # Python 2.4 fallback. +from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME -from django.http import HttpResponseRedirect from django.utils.decorators import available_attrs -from django.utils.http import urlquote def user_passes_test(test_func, login_url=None, redirect_field_name=REDIRECT_FIELD_NAME): @@ -15,18 +15,24 @@ def user_passes_test(test_func, login_url=None, redirect_field_name=REDIRECT_FIE redirecting to the log-in page if necessary. The test should be a callable that takes the user object and returns True if the user passes. """ - if not login_url: - from django.conf import settings - login_url = settings.LOGIN_URL def decorator(view_func): + @wraps(view_func, assigned=available_attrs(view_func)) def _wrapped_view(request, *args, **kwargs): if test_func(request.user): return view_func(request, *args, **kwargs) - path = urlquote(request.get_full_path()) - tup = login_url, redirect_field_name, path - return HttpResponseRedirect('%s?%s=%s' % tup) - return wraps(view_func, assigned=available_attrs(view_func))(_wrapped_view) + path = request.build_absolute_uri() + # If the login url is the same scheme and net location then just + # use the path as the "next" url. + login_scheme, login_netloc = urlparse.urlparse(login_url or + settings.LOGIN_URL)[:2] + current_scheme, current_netloc = urlparse.urlparse(path)[:2] + if ((not login_scheme or login_scheme == current_scheme) and + (not login_netloc or login_netloc == current_netloc)): + path = request.get_full_path() + from django.contrib.auth.views import redirect_to_login + return redirect_to_login(path, login_url, redirect_field_name) + return _wrapped_view return decorator diff --git a/django/contrib/auth/tests/__init__.py b/django/contrib/auth/tests/__init__.py index 9f8b49f400..442ee050b1 100644 --- a/django/contrib/auth/tests/__init__.py +++ b/django/contrib/auth/tests/__init__.py @@ -7,7 +7,7 @@ from django.contrib.auth.tests.remote_user \ from django.contrib.auth.tests.models import ProfileTestCase from django.contrib.auth.tests.signals import SignalTestCase from django.contrib.auth.tests.tokens import TokenGeneratorTest -from django.contrib.auth.tests.views \ - import PasswordResetTest, ChangePasswordTest, LoginTest, LogoutTest +from django.contrib.auth.tests.views import PasswordResetTest, \ + ChangePasswordTest, LoginTest, LogoutTest, LoginURLSettings # The password for the fixture data users is 'password' diff --git a/django/contrib/auth/tests/views.py b/django/contrib/auth/tests/views.py index e6c010bf1d..88867d538c 100644 --- a/django/contrib/auth/tests/views.py +++ b/django/contrib/auth/tests/views.py @@ -5,11 +5,12 @@ import urllib from django.conf import settings from django.contrib.auth import SESSION_KEY, REDIRECT_FIELD_NAME from django.contrib.auth.forms import AuthenticationForm -from django.contrib.sites.models import Site, RequestSite +from django.contrib.sites.models import Site from django.contrib.auth.models import User from django.test import TestCase from django.core import mail from django.core.urlresolvers import reverse +from django.http import QueryDict class AuthViewsTestCase(TestCase): """ @@ -25,11 +26,8 @@ class AuthViewsTestCase(TestCase): settings.LANGUAGE_CODE = 'en' self.old_TEMPLATE_DIRS = settings.TEMPLATE_DIRS settings.TEMPLATE_DIRS = ( - os.path.join( - os.path.dirname(__file__), - 'templates' - ) - ,) + os.path.join(os.path.dirname(__file__), 'templates'), + ) def tearDown(self): settings.LANGUAGES = self.old_LANGUAGES @@ -220,16 +218,20 @@ class LoginTest(AuthViewsTestCase): } ) self.assertEquals(response.status_code, 302) - self.assertFalse(bad_url in response['Location'], "%s should be blocked" % bad_url) + self.assertFalse(bad_url in response['Location'], + "%s should be blocked" % bad_url) - # Now, these URLs have an other URL as a GET parameter and therefore - # should be allowed - for url_ in ('http://example.com', 'https://example.com', - 'ftp://exampel.com', '//example.com'): - safe_url = '%(url)s?%(next)s=/view/?param=%(safe_param)s' % { + # These URLs *should* still pass the security check + for good_url in ('/view/?param=http://example.com', + '/view/?param=https://example.com', + '/view?param=ftp://exampel.com', + 'view/?param=//example.com', + 'https:///', + '//testserver/'): + safe_url = '%(url)s?%(next)s=%(good_url)s' % { 'url': login_url, 'next': REDIRECT_FIELD_NAME, - 'safe_param': urllib.quote(url_) + 'good_url': urllib.quote(good_url) } response = self.client.post(safe_url, { 'username': 'testclient', @@ -237,8 +239,66 @@ class LoginTest(AuthViewsTestCase): } ) self.assertEquals(response.status_code, 302) - self.assertTrue('/view/?param=%s' % url_ in response['Location'], "/view/?param=%s should be allowed" % url_) + self.assertTrue(good_url in response['Location'], + "%s should be allowed" % good_url) +class LoginURLSettings(AuthViewsTestCase): + urls = 'django.contrib.auth.tests.urls' + + def setUp(self): + super(LoginURLSettings, self).setUp() + self.old_LOGIN_URL = settings.LOGIN_URL + + def tearDown(self): + super(LoginURLSettings, self).tearDown() + settings.LOGIN_URL = self.old_LOGIN_URL + + def get_login_required_url(self, login_url): + settings.LOGIN_URL = login_url + response = self.client.get('/login_required/') + self.assertEquals(response.status_code, 302) + return response['Location'] + + def test_standard_login_url(self): + login_url = '/login/' + login_required_url = self.get_login_required_url(login_url) + querystring = QueryDict('', mutable=True) + querystring['next'] = '/login_required/' + self.assertEqual(login_required_url, + 'http://testserver%s?%s' % (login_url, querystring.urlencode())) + + def test_remote_login_url(self): + login_url = 'http://remote.example.com/login' + login_required_url = self.get_login_required_url(login_url) + querystring = QueryDict('', mutable=True) + querystring['next'] = 'http://testserver/login_required/' + self.assertEqual(login_required_url, + '%s?%s' % (login_url, querystring.urlencode())) + + def test_https_login_url(self): + login_url = 'https:///login/' + login_required_url = self.get_login_required_url(login_url) + querystring = QueryDict('', mutable=True) + querystring['next'] = 'http://testserver/login_required/' + self.assertEqual(login_required_url, + '%s?%s' % (login_url, querystring.urlencode())) + + def test_login_url_with_querystring(self): + login_url = '/login/?pretty=1' + login_required_url = self.get_login_required_url(login_url) + querystring = QueryDict('pretty=1', mutable=True) + querystring['next'] = '/login_required/' + self.assertEqual(login_required_url, 'http://testserver/login/?%s' % + querystring.urlencode()) + + def test_remote_login_url_with_next_querystring(self): + login_url = 'http://remote.example.com/login/' + login_required_url = self.get_login_required_url('%s?next=/default/' % + login_url) + querystring = QueryDict('', mutable=True) + querystring['next'] = 'http://testserver/login_required/' + self.assertEqual(login_required_url, '%s?%s' % (login_url, + querystring.urlencode())) class LogoutTest(AuthViewsTestCase): urls = 'django.contrib.auth.tests.urls' diff --git a/django/contrib/auth/views.py b/django/contrib/auth/views.py index 5b306e46c8..42ad68d3c3 100644 --- a/django/contrib/auth/views.py +++ b/django/contrib/auth/views.py @@ -1,4 +1,5 @@ import re +import urlparse from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME # Avoid shadowing the login() view below. @@ -11,9 +12,9 @@ from django.views.decorators.csrf import csrf_protect from django.core.urlresolvers import reverse from django.shortcuts import render_to_response, get_object_or_404 from django.contrib.sites.models import get_current_site -from django.http import HttpResponseRedirect, Http404 +from django.http import HttpResponseRedirect, Http404, QueryDict from django.template import RequestContext -from django.utils.http import urlquote, base36_to_int +from django.utils.http import base36_to_int from django.utils.translation import ugettext as _ from django.contrib.auth.models import User from django.views.decorators.cache import never_cache @@ -30,16 +31,16 @@ def login(request, template_name='registration/login.html', if request.method == "POST": form = authentication_form(data=request.POST) if form.is_valid(): + netloc = urlparse.urlparse(redirect_to)[1] + # Light security check -- make sure redirect_to isn't garbage. if not redirect_to or ' ' in redirect_to: redirect_to = settings.LOGIN_REDIRECT_URL - # Heavier security check -- redirects to http://example.com should - # not be allowed, but things like /view/?param=http://example.com - # should be allowed. This regex checks if there is a '//' *before* a - # question mark. - elif '//' in redirect_to and re.match(r'[^\?]*//', redirect_to): - redirect_to = settings.LOGIN_REDIRECT_URL + # Heavier security check -- don't allow redirection to a different + # host. + elif netloc and netloc != request.get_host(): + redirect_to = settings.LOGIN_REDIRECT_URL # Okay, security checks complete. Log the user in. auth_login(request, form.get_user()) @@ -88,11 +89,19 @@ def logout_then_login(request, login_url=None): login_url = settings.LOGIN_URL return logout(request, login_url) -def redirect_to_login(next, login_url=None, redirect_field_name=REDIRECT_FIELD_NAME): +def redirect_to_login(next, login_url=None, + redirect_field_name=REDIRECT_FIELD_NAME): "Redirects the user to the login page, passing the given 'next' page" if not login_url: login_url = settings.LOGIN_URL - return HttpResponseRedirect('%s?%s=%s' % (login_url, urlquote(redirect_field_name), urlquote(next))) + + login_url_parts = list(urlparse.urlparse(login_url)) + if redirect_field_name: + querystring = QueryDict(login_url_parts[4], mutable=True) + querystring[redirect_field_name] = next + login_url_parts[4] = querystring.urlencode() + + return HttpResponseRedirect(urlparse.urlunparse(login_url_parts)) # 4 views for password reset: # - password_reset sends the mail