Commit 77c7ebd7 by David Ormsbee

Merge pull request #2768 from carsongee/cg/ssl-next-url

Modified ssl certificate authentication to handle next redirection
parents b7003a1d cbf525f6
...@@ -9,7 +9,8 @@ from django.conf import settings ...@@ -9,7 +9,8 @@ from django.conf import settings
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from external_auth.views import ssl_login_shortcut, ssl_get_cert_from_request from external_auth.views import (ssl_login_shortcut, ssl_get_cert_from_request,
redirect_with_get)
from microsite_configuration import microsite from microsite_configuration import microsite
__all__ = ['signup', 'login_page', 'howitworks'] __all__ = ['signup', 'login_page', 'howitworks']
...@@ -26,7 +27,7 @@ def signup(request): ...@@ -26,7 +27,7 @@ def signup(request):
if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'): if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'):
# Redirect to course to login to process their certificate if SSL is enabled # Redirect to course to login to process their certificate if SSL is enabled
# and registration is disabled. # and registration is disabled.
return redirect(reverse('login')) return redirect_with_get('login', request.GET, False)
return render_to_response('register.html', {'csrf': csrf_token}) return render_to_response('register.html', {'csrf': csrf_token})
...@@ -43,7 +44,11 @@ def login_page(request): ...@@ -43,7 +44,11 @@ def login_page(request):
# SSL login doesn't require a login view, so redirect # SSL login doesn't require a login view, so redirect
# to course now that the user is authenticated via # to course now that the user is authenticated via
# the decorator. # the decorator.
return redirect('/course') next_url = request.GET.get('next')
if next_url:
return redirect(next_url)
else:
return redirect('/course')
if settings.FEATURES.get('AUTH_USE_CAS'): if settings.FEATURES.get('AUTH_USE_CAS'):
# If CAS is enabled, redirect auth handling to there # If CAS is enabled, redirect auth handling to there
return redirect(reverse('cas-login')) return redirect(reverse('cas-login'))
......
...@@ -21,19 +21,29 @@ from mock import Mock ...@@ -21,19 +21,29 @@ from mock import Mock
from edxmako.middleware import MakoMiddleware from edxmako.middleware import MakoMiddleware
from external_auth.models import ExternalAuthMap from external_auth.models import ExternalAuthMap
import external_auth.views import external_auth.views
from student.roles import CourseStaffRole
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from student.models import CourseEnrollment
from xmodule.modulestore import Location
from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.exceptions import InsufficientSpecificationError from xmodule.modulestore.exceptions import InsufficientSpecificationError
from xmodule.modulestore.tests.django_utils import (ModuleStoreTestCase,
mixed_store_config)
from xmodule.modulestore.tests.factories import CourseFactory
FEATURES_WITH_SSL_AUTH = settings.FEATURES.copy() FEATURES_WITH_SSL_AUTH = settings.FEATURES.copy()
FEATURES_WITH_SSL_AUTH['AUTH_USE_CERTIFICATES'] = True FEATURES_WITH_SSL_AUTH['AUTH_USE_CERTIFICATES'] = True
FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP = FEATURES_WITH_SSL_AUTH.copy() FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP = FEATURES_WITH_SSL_AUTH.copy()
FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP['AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'] = True FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP['AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'] = True
FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE = FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP.copy()
FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE['BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'] = True
FEATURES_WITHOUT_SSL_AUTH = settings.FEATURES.copy() FEATURES_WITHOUT_SSL_AUTH = settings.FEATURES.copy()
FEATURES_WITHOUT_SSL_AUTH['AUTH_USE_CERTIFICATES'] = False FEATURES_WITHOUT_SSL_AUTH['AUTH_USE_CERTIFICATES'] = False
TEST_DATA_MIXED_MODULESTORE = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {})
@override_settings(FEATURES=FEATURES_WITH_SSL_AUTH) @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH)
class SSLClientTest(TestCase): class SSLClientTest(ModuleStoreTestCase):
""" """
Tests SSL Authentication code sections of external_auth Tests SSL Authentication code sections of external_auth
""" """
...@@ -170,7 +180,8 @@ class SSLClientTest(TestCase): ...@@ -170,7 +180,8 @@ class SSLClientTest(TestCase):
response = self.client.get( response = self.client.get(
reverse('dashboard'), follow=True, reverse('dashboard'), follow=True,
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL)) SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL))
self.assertIn(reverse('dashboard'), response['location']) self.assertEquals(('http://testserver/dashboard', 302),
response.redirect_chain[-1])
self.assertIn(SESSION_KEY, self.client.session) self.assertIn(SESSION_KEY, self.client.session)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
...@@ -183,7 +194,8 @@ class SSLClientTest(TestCase): ...@@ -183,7 +194,8 @@ class SSLClientTest(TestCase):
response = self.client.get( response = self.client.get(
reverse('register_user'), follow=True, reverse('register_user'), follow=True,
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL)) SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL))
self.assertIn(reverse('dashboard'), response['location']) self.assertEquals(('http://testserver/dashboard', 302),
response.redirect_chain[-1])
self.assertIn(SESSION_KEY, self.client.session) self.assertIn(SESSION_KEY, self.client.session)
@unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms') @unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms')
...@@ -225,7 +237,8 @@ class SSLClientTest(TestCase): ...@@ -225,7 +237,8 @@ class SSLClientTest(TestCase):
response = self.client.get( response = self.client.get(
reverse('signin_user'), follow=True, reverse('signin_user'), follow=True,
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL)) SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL))
self.assertIn(reverse('dashboard'), response['location']) self.assertEquals(('http://testserver/dashboard', 302),
response.redirect_chain[-1])
self.assertIn(SESSION_KEY, self.client.session) self.assertIn(SESSION_KEY, self.client.session)
...@@ -316,3 +329,94 @@ class SSLClientTest(TestCase): ...@@ -316,3 +329,94 @@ class SSLClientTest(TestCase):
self.assertEqual(1, len(ExternalAuthMap.objects.all())) self.assertEqual(1, len(ExternalAuthMap.objects.all()))
self.assertTrue(self.mock.called) self.assertTrue(self.mock.called)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE,
MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
def test_ssl_lms_redirection(self):
"""
Auto signup auth user and ensure they return to the original
url they visited after being logged in.
"""
course = CourseFactory.create(
org='MITx',
number='999',
display_name='Robot Super Course'
)
external_auth.views.ssl_login(self._create_ssl_request('/'))
user = User.objects.get(email=self.USER_EMAIL)
CourseEnrollment.enroll(user, course.id)
course_private_url = '/courses/MITx/999/Robot_Super_Course/courseware'
self.assertFalse(SESSION_KEY in self.client.session)
response = self.client.get(
course_private_url,
follow=True,
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL),
HTTP_ACCEPT='text/html'
)
self.assertEqual(('http://testserver{0}'.format(course_private_url), 302),
response.redirect_chain[-1])
self.assertIn(SESSION_KEY, self.client.session)
@unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms')
@override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE)
def test_ssl_cms_redirection(self):
"""
Auto signup auth user and ensure they return to the original
url they visited after being logged in.
"""
course = CourseFactory.create(
org='MITx',
number='999',
display_name='Robot Super Course'
)
external_auth.views.ssl_login(self._create_ssl_request('/'))
user = User.objects.get(email=self.USER_EMAIL)
CourseEnrollment.enroll(user, course.id)
CourseStaffRole(course.location).add_users(user)
location = Location(['i4x', 'MITx', '999', 'course',
Location.clean('Robot Super Course'), None])
new_location = loc_mapper().translate_location(
location.course_id, location, True, True
)
course_private_url = new_location.url_reverse('course/', '')
self.assertFalse(SESSION_KEY in self.client.session)
response = self.client.get(
course_private_url,
follow=True,
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL),
HTTP_ACCEPT='text/html'
)
self.assertEqual(('http://testserver{0}'.format(course_private_url), 302),
response.redirect_chain[-1])
self.assertIn(SESSION_KEY, self.client.session)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE)
def test_ssl_logout(self):
"""
Because the branding view is cached for anonymous users and we
use that to login users, the browser wasn't actually making the
request to that view as the redirect was being cached. This caused
a redirect loop, and this test confirms that that won't happen.
Test is only in LMS because we don't use / in studio to login SSL users.
"""
response = self.client.get(
reverse('dashboard'), follow=True,
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL))
self.assertEquals(('http://testserver/dashboard', 302),
response.redirect_chain[-1])
self.assertIn(SESSION_KEY, self.client.session)
response = self.client.get(
reverse('logout'), follow=True,
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL)
)
# Make sure that even though we logged out, we have logged back in
self.assertIn(SESSION_KEY, self.client.session)
...@@ -440,7 +440,10 @@ def ssl_login(request): ...@@ -440,7 +440,10 @@ def ssl_login(request):
(_user, email, fullname) = _ssl_dn_extract_info(cert) (_user, email, fullname) = _ssl_dn_extract_info(cert)
retfun = functools.partial(redirect, '/') redirect_to = request.GET.get('next')
if not redirect_to:
redirect_to = '/'
retfun = functools.partial(redirect, redirect_to)
return _external_login_or_signup( return _external_login_or_signup(
request, request,
external_id=email, external_id=email,
...@@ -580,14 +583,14 @@ def course_specific_login(request, course_id): ...@@ -580,14 +583,14 @@ def course_specific_login(request, course_id):
course = course_from_id(course_id) course = course_from_id(course_id)
except ItemNotFoundError: except ItemNotFoundError:
# couldn't find the course, will just return vanilla signin page # couldn't find the course, will just return vanilla signin page
return _redirect_with_get_querydict('signin_user', request.GET) return redirect_with_get('signin_user', request.GET)
# now the dispatching conditionals. Only shib for now # now the dispatching conditionals. Only shib for now
if settings.FEATURES.get('AUTH_USE_SHIB') and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX): if settings.FEATURES.get('AUTH_USE_SHIB') and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX):
return _redirect_with_get_querydict('shib-login', request.GET) return redirect_with_get('shib-login', request.GET)
# Default fallthrough to normal signin page # Default fallthrough to normal signin page
return _redirect_with_get_querydict('signin_user', request.GET) return redirect_with_get('signin_user', request.GET)
def course_specific_register(request, course_id): def course_specific_register(request, course_id):
...@@ -599,24 +602,28 @@ def course_specific_register(request, course_id): ...@@ -599,24 +602,28 @@ def course_specific_register(request, course_id):
course = course_from_id(course_id) course = course_from_id(course_id)
except ItemNotFoundError: except ItemNotFoundError:
# couldn't find the course, will just return vanilla registration page # couldn't find the course, will just return vanilla registration page
return _redirect_with_get_querydict('register_user', request.GET) return redirect_with_get('register_user', request.GET)
# now the dispatching conditionals. Only shib for now # now the dispatching conditionals. Only shib for now
if settings.FEATURES.get('AUTH_USE_SHIB') and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX): if settings.FEATURES.get('AUTH_USE_SHIB') and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX):
# shib-login takes care of both registration and login flows # shib-login takes care of both registration and login flows
return _redirect_with_get_querydict('shib-login', request.GET) return redirect_with_get('shib-login', request.GET)
# Default fallthrough to normal registration page # Default fallthrough to normal registration page
return _redirect_with_get_querydict('register_user', request.GET) return redirect_with_get('register_user', request.GET)
def _redirect_with_get_querydict(view_name, get_querydict): def redirect_with_get(view_name, get_querydict, do_reverse=True):
""" """
Helper function to carry over get parameters across redirects Helper function to carry over get parameters across redirects
Using urlencode(safe='/') because the @login_required decorator generates 'next' queryparams with '/' unencoded Using urlencode(safe='/') because the @login_required decorator generates 'next' queryparams with '/' unencoded
""" """
if do_reverse:
url = reverse(view_name)
else:
url = view_name
if get_querydict: if get_querydict:
return redirect("%s?%s" % (reverse(view_name), get_querydict.urlencode(safe='/'))) return redirect("%s?%s" % (url, get_querydict.urlencode(safe='/')))
return redirect(view_name) return redirect(view_name)
......
...@@ -26,6 +26,7 @@ from django.shortcuts import redirect ...@@ -26,6 +26,7 @@ from django.shortcuts import redirect
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.utils.http import cookie_date, base36_to_int from django.utils.http import cookie_date, base36_to_int
from django.utils.translation import ugettext as _, get_language from django.utils.translation import ugettext as _, get_language
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_POST, require_GET from django.views.decorators.http import require_POST, require_GET
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
...@@ -327,7 +328,7 @@ def signin_user(request): ...@@ -327,7 +328,7 @@ def signin_user(request):
# SSL login doesn't require a view, so redirect # SSL login doesn't require a view, so redirect
# branding and allow that to process the login if it # branding and allow that to process the login if it
# is enabled and the header is in the request. # is enabled and the header is in the request.
return redirect(reverse('root')) return external_auth.views.redirect_with_get('root', request.GET)
if settings.FEATURES.get('AUTH_USE_CAS'): if settings.FEATURES.get('AUTH_USE_CAS'):
# If CAS is enabled, redirect auth handling to there # If CAS is enabled, redirect auth handling to there
return redirect(reverse('cas-login')) return redirect(reverse('cas-login'))
...@@ -360,7 +361,7 @@ def register_user(request, extra_context=None): ...@@ -360,7 +361,7 @@ def register_user(request, extra_context=None):
if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'): if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'):
# Redirect to branding to process their certificate if SSL is enabled # Redirect to branding to process their certificate if SSL is enabled
# and registration is disabled. # and registration is disabled.
return redirect(reverse('root')) return external_auth.views.redirect_with_get('root', request.GET)
context = { context = {
'course_id': request.GET.get('course_id'), 'course_id': request.GET.get('course_id'),
...@@ -674,6 +675,7 @@ def _get_course_enrollment_domain(course_id): ...@@ -674,6 +675,7 @@ def _get_course_enrollment_domain(course_id):
return None return None
@never_cache
@ensure_csrf_cookie @ensure_csrf_cookie
def accounts_login(request): def accounts_login(request):
""" """
...@@ -683,9 +685,9 @@ def accounts_login(request): ...@@ -683,9 +685,9 @@ def accounts_login(request):
if settings.FEATURES.get('AUTH_USE_CAS'): if settings.FEATURES.get('AUTH_USE_CAS'):
return redirect(reverse('cas-login')) return redirect(reverse('cas-login'))
if settings.FEATURES['AUTH_USE_CERTIFICATES']: if settings.FEATURES['AUTH_USE_CERTIFICATES']:
# SSL login doesn't require a view, so redirect # SSL login doesn't require a view, so login
# to branding and allow that to process the login. # directly here
return redirect(reverse('root')) return external_auth.views.ssl_login(request)
# see if the "next" parameter has been set, whether it has a course context, and if so, whether # see if the "next" parameter has been set, whether it has a course context, and if so, whether
# there is a course-specific place to redirect # there is a course-specific place to redirect
redirect_to = request.GET.get('next') redirect_to = request.GET.get('next')
......
...@@ -25,6 +25,12 @@ def index(request): ...@@ -25,6 +25,12 @@ def index(request):
if settings.FEATURES.get('AUTH_USE_CERTIFICATES'): if settings.FEATURES.get('AUTH_USE_CERTIFICATES'):
from external_auth.views import ssl_login from external_auth.views import ssl_login
# Set next URL to dashboard if it isn't set to avoid
# caching a redirect to / that causes a redirect loop on logout
if not request.GET.get('next'):
req_new = request.GET.copy()
req_new['next'] = reverse('dashboard')
request.GET = req_new
return ssl_login(request) return ssl_login(request)
enable_mktg_site = microsite.get_value( enable_mktg_site = microsite.get_value(
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment