Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions common/djangoapps/external_auth/tests/test_ssl.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
of the external_auth app.
"""

import logging
import StringIO
import unittest

from django.conf import settings
Expand All @@ -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)
Expand All @@ -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."""
Expand All @@ -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):
Expand Down Expand Up @@ -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"""

Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
Contributor Author

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.


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)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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)
36 changes: 29 additions & 7 deletions common/djangoapps/external_auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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('/')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down