-
Notifications
You must be signed in to change notification settings - Fork 4.3k
Fix external_auth to properly use retfun for @ssl_login_shortcut() #1862
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e868759
246fc03
07e235a
caf44c3
50e2e83
93b0357
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,8 @@ | |
| of the external_auth app. | ||
| """ | ||
|
|
||
| import logging | ||
| import StringIO | ||
| import unittest | ||
|
|
||
| from django.conf import settings | ||
|
|
@@ -13,14 +15,19 @@ | |
| from django.test.client import Client | ||
| from django.test.client import RequestFactory | ||
| from django.test.utils import override_settings | ||
| from mock import Mock | ||
|
|
||
| from edxmako.middleware import MakoMiddleware | ||
| from external_auth.models import ExternalAuthMap | ||
| import external_auth.views | ||
| from student.tests.factories import UserFactory | ||
|
|
||
| FEATURES_WITH_SSL_AUTH = settings.FEATURES.copy() | ||
| FEATURES_WITH_SSL_AUTH['AUTH_USE_MIT_CERTIFICATES'] = True | ||
| FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP = FEATURES_WITH_SSL_AUTH.copy() | ||
| FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP['AUTH_USE_MIT_CERTIFICATES_IMMEDIATE_SIGNUP'] = True | ||
| FEATURES_WITHOUT_SSL_AUTH = settings.FEATURES.copy() | ||
| FEATURES_WITHOUT_SSL_AUTH['AUTH_USE_MIT_CERTIFICATES'] = False | ||
|
|
||
|
|
||
| @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH) | ||
|
|
@@ -32,6 +39,7 @@ class SSLClientTest(TestCase): | |
| AUTH_DN = '/C=US/ST=Massachusetts/O=Massachusetts Institute of Technology/OU=Client CA v1/CN={0}/emailAddress={1}' | ||
| USER_NAME = 'test_user_ssl' | ||
| USER_EMAIL = 'test_user_ssl@EDX.ORG' | ||
| MOCK_URL = '/' | ||
|
|
||
| def _create_ssl_request(self, url): | ||
| """Creates a basic request for SSL use.""" | ||
|
|
@@ -41,13 +49,25 @@ def _create_ssl_request(self, url): | |
| middleware = SessionMiddleware() | ||
| middleware.process_request(request) | ||
| request.session.save() | ||
| MakoMiddleware().process_request(request) | ||
| return request | ||
|
|
||
| def _create_normal_request(self, url): | ||
| """Creates sessioned request without SSL headers""" | ||
| request = self.factory.get(url) | ||
| request.user = AnonymousUser() | ||
| middleware = SessionMiddleware() | ||
| middleware.process_request(request) | ||
| request.session.save() | ||
| MakoMiddleware().process_request(request) | ||
| return request | ||
|
|
||
| def setUp(self): | ||
| """Setup test case by adding primary user.""" | ||
| super(SSLClientTest, self).setUp() | ||
| self.client = Client() | ||
| self.factory = RequestFactory() | ||
| self.mock = Mock() | ||
|
|
||
| @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') | ||
| def test_ssl_login_with_signup_lms(self): | ||
|
|
@@ -184,3 +204,93 @@ def test_signin_page_bypass(self): | |
| SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL)) | ||
| self.assertIn(reverse('dashboard'), response['location']) | ||
| self.assertIn('_auth_user_id', self.client.session) | ||
|
|
||
| @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') | ||
| @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP) | ||
| def test_ssl_bad_eamap(self): | ||
| """ | ||
| This tests the response when a user exists but their eamap | ||
| password doesn't match their internal password. | ||
|
|
||
| This should start failing and can be removed when the | ||
| eamap.internal_password dependency is removed. | ||
| """ | ||
| external_auth.views.ssl_login(self._create_ssl_request('/')) | ||
| user = User.objects.get(email=self.USER_EMAIL) | ||
| user.set_password('not autogenerated') | ||
| user.save() | ||
|
|
||
| # Validate user failed by checking log | ||
| output = StringIO.StringIO() | ||
| audit_log_handler = logging.StreamHandler(output) | ||
| audit_log = logging.getLogger("audit") | ||
| audit_log.addHandler(audit_log_handler) | ||
|
|
||
| request = self._create_ssl_request('/') | ||
| external_auth.views.ssl_login(request) | ||
| self.assertIn('External Auth Login failed for', output.getvalue()) | ||
|
|
||
| @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') | ||
| @override_settings(FEATURES=FEATURES_WITHOUT_SSL_AUTH) | ||
| def test_ssl_decorator_no_certs(self): | ||
| """Make sure no external auth happens without SSL enabled""" | ||
|
|
||
| dec_mock = external_auth.views.ssl_login_shortcut(self.mock) | ||
| request = self._create_normal_request(self.MOCK_URL) | ||
| request.user = AnonymousUser() | ||
| # Call decorated mock function to make sure it passes | ||
| # the call through without hitting the external_auth functions and | ||
| # thereby creating an external auth map object. | ||
| dec_mock(request) | ||
| self.assertTrue(self.mock.called) | ||
| self.assertEqual(0, len(ExternalAuthMap.objects.all())) | ||
|
|
||
| @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') | ||
| def test_ssl_login_decorator(self): | ||
| """Create mock function to test ssl login decorator""" | ||
|
|
||
| dec_mock = external_auth.views.ssl_login_shortcut(self.mock) | ||
|
|
||
| # Test that anonymous without cert doesn't create authmap | ||
| request = self._create_normal_request(self.MOCK_URL) | ||
| dec_mock(request) | ||
| self.assertTrue(self.mock.called) | ||
| self.assertEqual(0, len(ExternalAuthMap.objects.all())) | ||
|
|
||
| # Test valid user | ||
| self.mock.reset_mock() | ||
| request = self._create_ssl_request(self.MOCK_URL) | ||
| dec_mock(request) | ||
| self.assertFalse(self.mock.called) | ||
| self.assertEqual(1, len(ExternalAuthMap.objects.all())) | ||
|
|
||
| # Test logged in user gets called | ||
| self.mock.reset_mock() | ||
| request = self._create_ssl_request(self.MOCK_URL) | ||
| request.user = UserFactory() | ||
| dec_mock(request) | ||
| self.assertTrue(self.mock.called) | ||
|
|
||
| @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') | ||
| @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP) | ||
| def test_ssl_decorator_auto_signup(self): | ||
| """ | ||
| Test that with auto signup the decorator | ||
| will bypass registration and call retfun. | ||
| """ | ||
|
|
||
| dec_mock = external_auth.views.ssl_login_shortcut(self.mock) | ||
| request = self._create_ssl_request(self.MOCK_URL) | ||
| dec_mock(request) | ||
| # Assert our user exists in both eamap and Users | ||
| try: | ||
| ExternalAuthMap.objects.get(external_id=self.USER_EMAIL) | ||
| except ExternalAuthMap.DoesNotExist, ex: | ||
| self.fail('User did not get properly added to external auth map, exception was {0}'.format(str(ex))) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see why you're catching this exception (to fail nicely with a nice message) but it causes there to be four uncovered lines in this test file (here and L245-246). @wedaly would you mind weighing in if this is the right way to write this test or not?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I just like the little red F's instead of Xs you know ;) I'm happy to change it to your preference. |
||
| try: | ||
| User.objects.get(email=self.USER_EMAIL) | ||
| except ExternalAuthMap.DoesNotExist, ex: | ||
| self.fail('User did not get properly added to internal users, exception was {0}'.format(str(ex))) | ||
| self.assertEqual(1, len(ExternalAuthMap.objects.all())) | ||
|
|
||
| self.assertTrue(self.mock.called) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -177,10 +177,10 @@ def _external_login_or_signup(request, | |
| return default_render_failure(request, failure_msg) | ||
| except User.DoesNotExist: | ||
| log.info('SHIB: No user for %s yet, doing signup', eamap.external_email) | ||
| return _signup(request, eamap) | ||
| return _signup(request, eamap, retfun) | ||
| else: | ||
| log.info('No user for %s yet. doing signup', eamap.external_email) | ||
| return _signup(request, eamap) | ||
| return _signup(request, eamap, retfun) | ||
|
|
||
| # We trust shib's authentication, so no need to authenticate using the password again | ||
| uname = internal_user.username | ||
|
|
@@ -198,7 +198,7 @@ def _external_login_or_signup(request, | |
| if user is None: | ||
| # we want to log the failure, but don't want to log the password attempted: | ||
| AUDIT_LOG.warning('External Auth Login failed for "%s"', uname) | ||
| return _signup(request, eamap) | ||
| return _signup(request, eamap, retfun) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Code coverage suggests that this line isn't tested. Is this something easy to add a test for?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added a test for this. |
||
|
|
||
| if not user.is_active: | ||
| AUDIT_LOG.warning('User "%s" is not active after external login', uname) | ||
|
|
@@ -237,7 +237,7 @@ def _flatten_to_ascii(txt): | |
|
|
||
|
|
||
| @ensure_csrf_cookie | ||
| def _signup(request, eamap): | ||
| def _signup(request, eamap, retfun=None): | ||
| """ | ||
| Present form to complete for signup via external authentication. | ||
| Even though the user has external credentials, he/she still needs | ||
|
|
@@ -246,6 +246,9 @@ def _signup(request, eamap): | |
|
|
||
| eamap is an ExternalAuthMap object, specifying the external user | ||
| for which to complete the signup. | ||
|
|
||
| retfun is a function to execute for the return value, if immediate | ||
| signup is used. That allows @ssl_login_shortcut() to work. | ||
| """ | ||
| # save this for use by student.views.create_account | ||
| request.session['ExternalAuthMap'] = eamap | ||
|
|
@@ -260,7 +263,11 @@ def _signup(request, eamap): | |
| terms_of_service=u'true') | ||
| log.info('doing immediate signup for %s, params=%s', username, post_vars) | ||
| student.views.create_account(request, post_vars) | ||
| return redirect('/') | ||
| # should check return content for successful completion before | ||
| if retfun is not None: | ||
| return retfun() | ||
| else: | ||
| return redirect('/') | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Code coverage suggests that this line isn't tested. Is this something easy to add a test for?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @brianhw Both of our auth entry functions set retfun, so this can't be hit without calling _external_signup directly. Should I make an "artificial" test directly to _signup with retfun set to None to test, or be bold and remove the safety redirect?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's also called by openid_login_complete(), appearing above it. And in that case the retfun is not set. So don't remove the redirect, unless you move it up into openid_login_complete(). Which is a reasonable option (and remove the default value for retfun=None).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I honestly hadn't thought of what it would look like to have those both turned on. I'll leave it in as it is worth handling either way. |
||
|
|
||
| # default conjoin name, no spaces, flattened to ascii b/c django can't handle unicode usernames, sadly | ||
| # but this only affects username, not fullname | ||
|
|
@@ -349,21 +356,36 @@ def ssl_login_shortcut(fn): | |
| based on existing ExternalAuth record and MIT ssl certificate. | ||
| """ | ||
| def wrapped(*args, **kwargs): | ||
| """ | ||
| This manages the function wrapping, by determining whether to inject | ||
| the _external signup or just continuing to the internal function | ||
| call. | ||
| """ | ||
|
|
||
| if not settings.FEATURES['AUTH_USE_MIT_CERTIFICATES']: | ||
| return fn(*args, **kwargs) | ||
| request = args[0] | ||
|
|
||
| if request.user and request.user.is_authenticated(): # don't re-authenticate | ||
| return fn(*args, **kwargs) | ||
|
|
||
| cert = _ssl_get_cert_from_request(request) | ||
| if not cert: # no certificate information - show normal login window | ||
| return fn(*args, **kwargs) | ||
|
|
||
| def retfun(): | ||
| """Wrap function again for call by _external_login_or_signup""" | ||
| return fn(*args, **kwargs) | ||
|
|
||
| (_user, email, fullname) = _ssl_dn_extract_info(cert) | ||
| return _external_login_or_signup( | ||
| request, | ||
| external_id=email, | ||
| external_domain="ssl:MIT", | ||
| credentials=cert, | ||
| email=email, | ||
| fullname=fullname | ||
| fullname=fullname, | ||
| retfun=retfun | ||
| ) | ||
| return wrapped | ||
|
|
||
|
|
@@ -397,7 +419,7 @@ def ssl_login(request): | |
|
|
||
| (_user, email, fullname) = _ssl_dn_extract_info(cert) | ||
|
|
||
| retfun = functools.partial(student.views.index, request) | ||
| retfun = functools.partial(redirect, '/') | ||
| return _external_login_or_signup( | ||
| request, | ||
| external_id=email, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't typically provide docstrings for unittests (see http://stackoverflow.com/questions/12962772/how-to-stop-python-unittest-from-printing-test-docstring) -- only for helper functions for tests. NBD here but for future development, now you know.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll keep that in mind for the future.