From fd49f88e080692c0bb3704ee2151c31b436a69ca Mon Sep 17 00:00:00 2001 From: Nimisha Asthagiri <nasthagiri@edx.org> Date: Wed, 5 Oct 2016 21:29:29 -0400 Subject: [PATCH] Moves external_auth from common to openedx/core. --- cms/djangoapps/contentstore/views/public.py | 7 +++++-- cms/envs/common.py | 2 +- cms/urls.py | 2 +- common/djangoapps/external_auth/__init__.py | 0 common/djangoapps/external_auth/admin.py | 13 ------------- common/djangoapps/external_auth/djangostore.py | 123 --------------------------------------------------------------------------------------------------------------------------- common/djangoapps/external_auth/login_and_register.py | 92 -------------------------------------------------------------------------------------------- common/djangoapps/external_auth/migrations/0001_initial.py | 34 ---------------------------------- common/djangoapps/external_auth/migrations/__init__.py | 0 common/djangoapps/external_auth/models.py | 33 --------------------------------- common/djangoapps/external_auth/tests/__init__.py | 0 common/djangoapps/external_auth/tests/test_helper.py | 29 ----------------------------- common/djangoapps/external_auth/tests/test_openid_provider.py | 447 --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- common/djangoapps/external_auth/tests/test_shib.py | 595 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- common/djangoapps/external_auth/tests/test_ssl.py | 417 --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- common/djangoapps/external_auth/views.py | 947 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- common/djangoapps/student/tests/test_create_account.py | 2 +- common/djangoapps/student/tests/test_login.py | 2 +- common/djangoapps/student/tests/test_password_policy.py | 2 +- common/djangoapps/student/views.py | 16 ++++++++-------- docs/en_us/platform_api/source/conf.py | 2 +- lms/djangoapps/branding/views.py | 2 +- lms/djangoapps/courseware/access.py | 2 +- lms/djangoapps/dashboard/sysadmin.py | 4 ++-- lms/djangoapps/lms_migration/management/commands/create_user.py | 2 +- lms/djangoapps/student_account/views.py | 2 +- lms/envs/common.py | 2 +- lms/urls.py | 34 +++++++++++++++++++++++++--------- openedx/core/djangoapps/external_auth/__init__.py | 0 openedx/core/djangoapps/external_auth/admin.py | 16 ++++++++++++++++ openedx/core/djangoapps/external_auth/djangostore.py | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ openedx/core/djangoapps/external_auth/login_and_register.py | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ openedx/core/djangoapps/external_auth/migrations/0001_initial.py | 34 ++++++++++++++++++++++++++++++++++ openedx/core/djangoapps/external_auth/migrations/__init__.py | 0 openedx/core/djangoapps/external_auth/models.py | 35 +++++++++++++++++++++++++++++++++++ openedx/core/djangoapps/external_auth/tests/__init__.py | 0 openedx/core/djangoapps/external_auth/tests/test_helper.py | 29 +++++++++++++++++++++++++++++ openedx/core/djangoapps/external_auth/tests/test_openid_provider.py | 473 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ openedx/core/djangoapps/external_auth/tests/test_shib.py | 597 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ openedx/core/djangoapps/external_auth/tests/test_ssl.py | 418 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ openedx/core/djangoapps/external_auth/views.py | 954 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 41 files changed, 2837 insertions(+), 2762 deletions(-) delete mode 100644 common/djangoapps/external_auth/__init__.py delete mode 100644 common/djangoapps/external_auth/admin.py delete mode 100644 common/djangoapps/external_auth/djangostore.py delete mode 100644 common/djangoapps/external_auth/login_and_register.py delete mode 100644 common/djangoapps/external_auth/migrations/0001_initial.py delete mode 100644 common/djangoapps/external_auth/migrations/__init__.py delete mode 100644 common/djangoapps/external_auth/models.py delete mode 100644 common/djangoapps/external_auth/tests/__init__.py delete mode 100644 common/djangoapps/external_auth/tests/test_helper.py delete mode 100644 common/djangoapps/external_auth/tests/test_openid_provider.py delete mode 100644 common/djangoapps/external_auth/tests/test_shib.py delete mode 100644 common/djangoapps/external_auth/tests/test_ssl.py delete mode 100644 common/djangoapps/external_auth/views.py create mode 100644 openedx/core/djangoapps/external_auth/__init__.py create mode 100644 openedx/core/djangoapps/external_auth/admin.py create mode 100644 openedx/core/djangoapps/external_auth/djangostore.py create mode 100644 openedx/core/djangoapps/external_auth/login_and_register.py create mode 100644 openedx/core/djangoapps/external_auth/migrations/0001_initial.py create mode 100644 openedx/core/djangoapps/external_auth/migrations/__init__.py create mode 100644 openedx/core/djangoapps/external_auth/models.py create mode 100644 openedx/core/djangoapps/external_auth/tests/__init__.py create mode 100644 openedx/core/djangoapps/external_auth/tests/test_helper.py create mode 100644 openedx/core/djangoapps/external_auth/tests/test_openid_provider.py create mode 100644 openedx/core/djangoapps/external_auth/tests/test_shib.py create mode 100644 openedx/core/djangoapps/external_auth/tests/test_ssl.py create mode 100644 openedx/core/djangoapps/external_auth/views.py diff --git a/cms/djangoapps/contentstore/views/public.py b/cms/djangoapps/contentstore/views/public.py index 1a2ca08..5aeb8c8 100644 --- a/cms/djangoapps/contentstore/views/public.py +++ b/cms/djangoapps/contentstore/views/public.py @@ -10,8 +10,11 @@ from django.conf import settings from edxmako.shortcuts import render_to_response -from external_auth.views import (ssl_login_shortcut, ssl_get_cert_from_request, - redirect_with_get) +from openedx.core.djangoapps.external_auth.views import ( + ssl_login_shortcut, + ssl_get_cert_from_request, + redirect_with_get, +) from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers __all__ = ['signup', 'login_page', 'howitworks'] diff --git a/cms/envs/common.py b/cms/envs/common.py index 1c82fa4..7eb296d 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -817,7 +817,7 @@ INSTALLED_APPS = ( 'contentstore', 'contentserver', 'course_creators', - 'external_auth', + 'openedx.core.djangoapps.external_auth', 'student', # misleading name due to sharing with lms 'openedx.core.djangoapps.course_groups', # not used in cms (yet), but tests run 'openedx.core.djangoapps.coursetalk', # not used in cms (yet), but tests run diff --git a/cms/urls.py b/cms/urls.py index d4e2c08..7b8f4fe 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -153,7 +153,7 @@ if settings.FEATURES.get('ENABLE_SERVICE_STATUS'): if settings.FEATURES.get('AUTH_USE_CAS'): urlpatterns += ( - url(r'^cas-auth/login/$', 'external_auth.views.cas_login', name="cas-login"), + url(r'^cas-auth/login/$', 'openedx.core.djangoapps.external_auth.views.cas_login', name="cas-login"), url(r'^cas-auth/logout/$', 'django_cas.views.logout', {'next_page': '/'}, name="cas-logout"), ) diff --git a/common/djangoapps/external_auth/__init__.py b/common/djangoapps/external_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/common/djangoapps/external_auth/__init__.py +++ /dev/null diff --git a/common/djangoapps/external_auth/admin.py b/common/djangoapps/external_auth/admin.py deleted file mode 100644 index fad0917..0000000 --- a/common/djangoapps/external_auth/admin.py +++ /dev/null @@ -1,13 +0,0 @@ -''' -django admin pages for courseware model -''' - -from external_auth.models import * -from ratelimitbackend import admin - - -class ExternalAuthMapAdmin(admin.ModelAdmin): - search_fields = ['external_id', 'user__username'] - date_hierarchy = 'dtcreated' - -admin.site.register(ExternalAuthMap, ExternalAuthMapAdmin) diff --git a/common/djangoapps/external_auth/djangostore.py b/common/djangoapps/external_auth/djangostore.py deleted file mode 100644 index 8b830e0..0000000 --- a/common/djangoapps/external_auth/djangostore.py +++ /dev/null @@ -1,123 +0,0 @@ -"""A openid store using django cache""" - -from openid.store.interface import OpenIDStore -from openid.store import nonce - -from django.core.cache import cache - -import logging -import time - -DEFAULT_ASSOCIATIONS_TIMEOUT = 60 -DEFAULT_NONCE_TIMEOUT = 600 - -ASSOCIATIONS_KEY_PREFIX = 'openid.provider.associations.' -NONCE_KEY_PREFIX = 'openid.provider.nonce.' - -log = logging.getLogger('DjangoOpenIDStore') - - -def get_url_key(server_url): - key = ASSOCIATIONS_KEY_PREFIX + server_url - return key - - -def get_nonce_key(server_url, timestamp, salt): - key = '{prefix}{url}.{ts}.{salt}'.format(prefix=NONCE_KEY_PREFIX, - url=server_url, - ts=timestamp, - salt=salt) - return key - - -class DjangoOpenIDStore(OpenIDStore): - def __init__(self): - log.info('DjangoStore cache:' + str(cache.__class__)) - - def storeAssociation(self, server_url, assoc): - key = get_url_key(server_url) - - log.info('storeAssociation {0}'.format(key)) - - associations = cache.get(key, {}) - associations[assoc.handle] = assoc - - cache.set(key, associations, DEFAULT_ASSOCIATIONS_TIMEOUT) - - def getAssociation(self, server_url, handle=None): - key = get_url_key(server_url) - - log.info('getAssociation {0}'.format(key)) - - associations = cache.get(key, {}) - - assoc = None - - if handle is None: - # get best association - valid_assocs = [a for a in associations if a.getExpiresIn() > 0] - if valid_assocs: - valid_assocs.sort(lambda a: a.getExpiresIn(), reverse=True) - assoc = valid_assocs.sort[0] - else: - assoc = associations.get(handle) - - # check expiration and remove if it has expired - if assoc and assoc.getExpiresIn() <= 0: - if handle is None: - cache.delete(key) - else: - associations.pop(handle) - cache.set(key, associations, DEFAULT_ASSOCIATIONS_TIMEOUT) - assoc = None - - return assoc - - def removeAssociation(self, server_url, handle): - key = get_url_key(server_url) - - log.info('removeAssociation {0}'.format(key)) - - associations = cache.get(key, {}) - - removed = False - - if associations: - if handle is None: - cache.delete(key) - removed = True - else: - assoc = associations.pop(handle, None) - if assoc: - cache.set(key, associations, DEFAULT_ASSOCIATIONS_TIMEOUT) - removed = True - - return removed - - def useNonce(self, server_url, timestamp, salt): - key = get_nonce_key(server_url, timestamp, salt) - - log.info('useNonce {0}'.format(key)) - - if abs(timestamp - time.time()) > nonce.SKEW: - return False - - anonce = cache.get(key) - - found = False - - if anonce is None: - cache.set(key, '-', DEFAULT_NONCE_TIMEOUT) - found = False - else: - found = True - - return found - - def cleanupNonces(self): - # not necesary, keys will timeout - return 0 - - def cleanupAssociations(self): - # not necesary, keys will timeout - return 0 diff --git a/common/djangoapps/external_auth/login_and_register.py b/common/djangoapps/external_auth/login_and_register.py deleted file mode 100644 index fa4ab2e..0000000 --- a/common/djangoapps/external_auth/login_and_register.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Intercept login and registration requests. - -This module contains legacy code originally from `student.views`. -""" -import re - -from django.conf import settings -from django.shortcuts import redirect -from django.core.urlresolvers import reverse -import external_auth.views - -from xmodule.modulestore.django import modulestore -from opaque_keys.edx.keys import CourseKey - - -# pylint: disable=fixme -# TODO: This function is kind of gnarly/hackish/etc and is only used in one location. -# It'd be awesome if we could get rid of it; manually parsing course_id strings form larger strings -# seems Probably Incorrect -def _parse_course_id_from_string(input_str): - """ - Helper function to determine if input_str (typically the queryparam 'next') contains a course_id. - @param input_str: - @return: the course_id if found, None if not - """ - m_obj = re.match(r'^/courses/{}'.format(settings.COURSE_ID_PATTERN), input_str) - if m_obj: - return CourseKey.from_string(m_obj.group('course_id')) - return None - - -def _get_course_enrollment_domain(course_id): - """ - Helper function to get the enrollment domain set for a course with id course_id - @param course_id: - @return: - """ - course = modulestore().get_course(course_id) - if course is None: - return None - - return course.enrollment_domain - - -def login(request): - """Allow external auth to intercept and handle a login request. - - Arguments: - request (Request): A request for the login page. - - Returns: - Response or None - - """ - # Default to a `None` response, indicating that external auth - # is not handling the request. - response = None - - if settings.FEATURES['AUTH_USE_CERTIFICATES'] and external_auth.views.ssl_get_cert_from_request(request): - # SSL login doesn't require a view, so redirect - # branding and allow that to process the login if it - # is enabled and the header is in the request. - response = external_auth.views.redirect_with_get('root', request.GET) - elif settings.FEATURES.get('AUTH_USE_CAS'): - # If CAS is enabled, redirect auth handling to there - response = redirect(reverse('cas-login')) - elif settings.FEATURES.get('AUTH_USE_SHIB'): - redirect_to = request.GET.get('next') - if redirect_to: - course_id = _parse_course_id_from_string(redirect_to) - if course_id and _get_course_enrollment_domain(course_id): - response = external_auth.views.course_specific_login(request, course_id.to_deprecated_string()) - - return response - - -def register(request): - """Allow external auth to intercept and handle a registration request. - - Arguments: - request (Request): A request for the registration page. - - Returns: - Response or None - - """ - response = None - if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'): - # Redirect to branding to process their certificate if SSL is enabled - # and registration is disabled. - response = external_auth.views.redirect_with_get('root', request.GET) - return response diff --git a/common/djangoapps/external_auth/migrations/0001_initial.py b/common/djangoapps/external_auth/migrations/0001_initial.py deleted file mode 100644 index f68b8f0..0000000 --- a/common/djangoapps/external_auth/migrations/0001_initial.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models -from django.conf import settings - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='ExternalAuthMap', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('external_id', models.CharField(max_length=255, db_index=True)), - ('external_domain', models.CharField(max_length=255, db_index=True)), - ('external_credentials', models.TextField(blank=True)), - ('external_email', models.CharField(max_length=255, db_index=True)), - ('external_name', models.CharField(db_index=True, max_length=255, blank=True)), - ('internal_password', models.CharField(max_length=31, blank=True)), - ('dtcreated', models.DateTimeField(auto_now_add=True, verbose_name=b'creation date')), - ('dtsignup', models.DateTimeField(null=True, verbose_name=b'signup date')), - ('user', models.OneToOneField(null=True, to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.AlterUniqueTogether( - name='externalauthmap', - unique_together=set([('external_id', 'external_domain')]), - ), - ] diff --git a/common/djangoapps/external_auth/migrations/__init__.py b/common/djangoapps/external_auth/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/common/djangoapps/external_auth/migrations/__init__.py +++ /dev/null diff --git a/common/djangoapps/external_auth/models.py b/common/djangoapps/external_auth/models.py deleted file mode 100644 index 5630725..0000000 --- a/common/djangoapps/external_auth/models.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -WE'RE USING MIGRATIONS! - -If you make changes to this model, be sure to create an appropriate migration -file and check it in at the same time as your model changes. To do that, - -1. Go to the edx-platform dir -2. ./manage.py lms schemamigration student --auto description_of_your_change -3. Add the migration file created in edx-platform/common/djangoapps/external_auth/migrations/ -""" - -from django.db import models -from django.contrib.auth.models import User - - -class ExternalAuthMap(models.Model): - class Meta(object): - app_label = "external_auth" - unique_together = (('external_id', 'external_domain'), ) - - external_id = models.CharField(max_length=255, db_index=True) - external_domain = models.CharField(max_length=255, db_index=True) - external_credentials = models.TextField(blank=True) # JSON dictionary - external_email = models.CharField(max_length=255, db_index=True) - external_name = models.CharField(blank=True, max_length=255, db_index=True) - user = models.OneToOneField(User, unique=True, db_index=True, null=True) - internal_password = models.CharField(blank=True, max_length=31) # randomly generated - dtcreated = models.DateTimeField('creation date', auto_now_add=True) - dtsignup = models.DateTimeField('signup date', null=True) # set after signup - - def __unicode__(self): - s = "[%s] = (%s / %s)" % (self.external_id, self.external_name, self.external_email) - return s diff --git a/common/djangoapps/external_auth/tests/__init__.py b/common/djangoapps/external_auth/tests/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/common/djangoapps/external_auth/tests/__init__.py +++ /dev/null diff --git a/common/djangoapps/external_auth/tests/test_helper.py b/common/djangoapps/external_auth/tests/test_helper.py deleted file mode 100644 index b7cacae..0000000 --- a/common/djangoapps/external_auth/tests/test_helper.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Tests for utility functions in external_auth module -""" -from django.test import TestCase -from external_auth.views import _safe_postlogin_redirect - - -class ExternalAuthHelperFnTest(TestCase): - """ - Unit tests for the external_auth.views helper function - """ - def test__safe_postlogin_redirect(self): - """ - Tests the _safe_postlogin_redirect function with different values of next - """ - HOST = 'testserver' # pylint: disable=invalid-name - ONSITE1 = '/dashboard' # pylint: disable=invalid-name - ONSITE2 = '/courses/org/num/name/courseware' # pylint: disable=invalid-name - ONSITE3 = 'http://{}/my/custom/url'.format(HOST) # pylint: disable=invalid-name - OFFSITE1 = 'http://www.attacker.com' # pylint: disable=invalid-name - - for redirect_to in [ONSITE1, ONSITE2, ONSITE3]: - redir = _safe_postlogin_redirect(redirect_to, HOST) - self.assertEqual(redir.status_code, 302) - self.assertEqual(redir['location'], redirect_to) - - redir2 = _safe_postlogin_redirect(OFFSITE1, HOST) - self.assertEqual(redir2.status_code, 302) - self.assertEqual("/", redir2['location']) diff --git a/common/djangoapps/external_auth/tests/test_openid_provider.py b/common/djangoapps/external_auth/tests/test_openid_provider.py deleted file mode 100644 index e2fff63..0000000 --- a/common/djangoapps/external_auth/tests/test_openid_provider.py +++ /dev/null @@ -1,447 +0,0 @@ -#-*- encoding=utf-8 -*- -''' -Created on Jan 18, 2013 - -@author: brian -''' -import openid -from openid.fetchers import HTTPFetcher, HTTPResponse -from urlparse import parse_qs, urlparse - -from django.conf import settings -from django.test import TestCase, LiveServerTestCase -from django.core.cache import cache -from django.test.utils import override_settings -from django.core.urlresolvers import reverse -from django.test.client import RequestFactory -from unittest import skipUnless - -from student.tests.factories import UserFactory -from external_auth.views import provider_login - - -class MyFetcher(HTTPFetcher): - """A fetcher that uses server-internal calls for performing HTTP - requests. - """ - - def __init__(self, client): - """@param client: A test client object""" - - super(MyFetcher, self).__init__() - self.client = client - - def fetch(self, url, body=None, headers=None): - """Perform an HTTP request - - @raises Exception: Any exception that can be raised by Django - - @see: C{L{HTTPFetcher.fetch}} - """ - if body: - # method = 'POST' - # undo the URL encoding of the POST arguments - data = parse_qs(body) - response = self.client.post(url, data) - else: - # method = 'GET' - data = {} - if headers and 'Accept' in headers: - data['CONTENT_TYPE'] = headers['Accept'] - response = self.client.get(url, data) - - # Translate the test client response to the fetcher's HTTP response abstraction - content = response.content - final_url = url - response_headers = {} - if 'Content-Type' in response: - response_headers['content-type'] = response['Content-Type'] - if 'X-XRDS-Location' in response: - response_headers['x-xrds-location'] = response['X-XRDS-Location'] - status = response.status_code - - return HTTPResponse( - body=content, - final_url=final_url, - headers=response_headers, - status=status, - ) - - -class OpenIdProviderTest(TestCase): - """ - Tests of the OpenId login - """ - @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID') and - settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), - 'OpenID not enabled') - def test_begin_login_with_xrds_url(self): - - # the provider URL must be converted to an absolute URL in order to be - # used as an openid provider. - provider_url = reverse('openid-provider-xrds') - factory = RequestFactory() - request = factory.request() - abs_provider_url = request.build_absolute_uri(location=provider_url) - - # In order for this absolute URL to work (i.e. to get xrds, then authentication) - # in the test environment, we either need a live server that works with the default - # fetcher (i.e. urlopen2), or a test server that is reached through a custom fetcher. - # Here we do the latter: - fetcher = MyFetcher(self.client) - openid.fetchers.setDefaultFetcher(fetcher, wrap_exceptions=False) - - # now we can begin the login process by invoking a local openid client, - # with a pointer to the (also-local) openid provider: - with self.settings(OPENID_SSO_SERVER_URL=abs_provider_url): - - url = reverse('openid-login') - resp = self.client.post(url) - code = 200 - self.assertEqual(resp.status_code, code, - "got code {0} for url '{1}'. Expected code {2}" - .format(resp.status_code, url, code)) - - @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID') and - settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), - 'OpenID not enabled') - def test_begin_login_with_login_url(self): - - # the provider URL must be converted to an absolute URL in order to be - # used as an openid provider. - provider_url = reverse('openid-provider-login') - factory = RequestFactory() - request = factory.request() - abs_provider_url = request.build_absolute_uri(location=provider_url) - - # In order for this absolute URL to work (i.e. to get xrds, then authentication) - # in the test environment, we either need a live server that works with the default - # fetcher (i.e. urlopen2), or a test server that is reached through a custom fetcher. - # Here we do the latter: - fetcher = MyFetcher(self.client) - openid.fetchers.setDefaultFetcher(fetcher, wrap_exceptions=False) - - # now we can begin the login process by invoking a local openid client, - # with a pointer to the (also-local) openid provider: - with self.settings(OPENID_SSO_SERVER_URL=abs_provider_url): - url = reverse('openid-login') - resp = self.client.post(url) - code = 200 - self.assertEqual(resp.status_code, code, - "got code {0} for url '{1}'. Expected code {2}" - .format(resp.status_code, url, code)) - self.assertContains(resp, '<input name="openid.mode" type="hidden" value="checkid_setup" />', html=True) - self.assertContains(resp, '<input name="openid.ns" type="hidden" value="http://specs.openid.net/auth/2.0" />', html=True) - self.assertContains(resp, '<input name="openid.identity" type="hidden" value="http://specs.openid.net/auth/2.0/identifier_select" />', html=True) - self.assertContains(resp, '<input name="openid.claimed_id" type="hidden" value="http://specs.openid.net/auth/2.0/identifier_select" />', html=True) - self.assertContains(resp, '<input name="openid.ns.ax" type="hidden" value="http://openid.net/srv/ax/1.0" />', html=True) - self.assertContains(resp, '<input name="openid.ax.mode" type="hidden" value="fetch_request" />', html=True) - self.assertContains(resp, '<input name="openid.ax.required" type="hidden" value="email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname" />', html=True) - self.assertContains(resp, '<input name="openid.ax.type.fullname" type="hidden" value="http://axschema.org/namePerson" />', html=True) - self.assertContains(resp, '<input name="openid.ax.type.lastname" type="hidden" value="http://axschema.org/namePerson/last" />', html=True) - self.assertContains(resp, '<input name="openid.ax.type.firstname" type="hidden" value="http://axschema.org/namePerson/first" />', html=True) - self.assertContains(resp, '<input name="openid.ax.type.nickname" type="hidden" value="http://axschema.org/namePerson/friendly" />', html=True) - self.assertContains(resp, '<input name="openid.ax.type.email" type="hidden" value="http://axschema.org/contact/email" />', html=True) - self.assertContains(resp, '<input name="openid.ax.type.old_email" type="hidden" value="http://schema.openid.net/contact/email" />', html=True) - self.assertContains(resp, '<input name="openid.ax.type.old_nickname" type="hidden" value="http://schema.openid.net/namePerson/friendly" />', html=True) - self.assertContains(resp, '<input name="openid.ax.type.old_fullname" type="hidden" value="http://schema.openid.net/namePerson" />', html=True) - self.assertContains(resp, '<input type="submit" value="Continue" />', html=True) - # this should work on the server: - self.assertContains(resp, '<input name="openid.realm" type="hidden" value="http://testserver/" />', html=True) - - # not included here are elements that will vary from run to run: - # <input name="openid.return_to" type="hidden" value="http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H" /> - # <input name="openid.assoc_handle" type="hidden" value="{HMAC-SHA1}{50ff8120}{rh87+Q==}" /> - - def attempt_login(self, expected_code, login_method='POST', **kwargs): - """ Attempt to log in through the open id provider login """ - url = reverse('openid-provider-login') - args = { - "openid.mode": "checkid_setup", - "openid.return_to": "http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H", - "openid.assoc_handle": "{HMAC-SHA1}{50ff8120}{rh87+Q==}", - "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select", - "openid.ns": "http://specs.openid.net/auth/2.0", - "openid.realm": "http://testserver/", - "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select", - "openid.ns.ax": "http://openid.net/srv/ax/1.0", - "openid.ax.mode": "fetch_request", - "openid.ax.required": "email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname", - "openid.ax.type.fullname": "http://axschema.org/namePerson", - "openid.ax.type.lastname": "http://axschema.org/namePerson/last", - "openid.ax.type.firstname": "http://axschema.org/namePerson/first", - "openid.ax.type.nickname": "http://axschema.org/namePerson/friendly", - "openid.ax.type.email": "http://axschema.org/contact/email", - "openid.ax.type.old_email": "http://schema.openid.net/contact/email", - "openid.ax.type.old_nickname": "http://schema.openid.net/namePerson/friendly", - "openid.ax.type.old_fullname": "http://schema.openid.net/namePerson", - } - # override the default args with any given arguments - for key in kwargs: - args["openid." + key] = kwargs[key] - - if login_method == 'POST': - resp = self.client.post(url, args) - elif login_method == 'GET': - resp = self.client.get(url, args) - else: - self.fail('Invalid login method') - - code = expected_code - self.assertEqual(resp.status_code, code, - "got code {0} for url '{1}'. Expected code {2}" - .format(resp.status_code, url, code)) - - @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID') and - settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), - 'OpenID not enabled') - def test_open_id_setup(self): - """ Attempt a standard successful login """ - self.attempt_login(200) - - @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID') and - settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), - 'OpenID not enabled') - def test_invalid_namespace(self): - """ Test for 403 error code when the namespace of the request is invalid""" - self.attempt_login(403, ns="http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0") - - @override_settings(OPENID_PROVIDER_TRUSTED_ROOTS=['http://apps.cs50.edx.org']) - @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID') and - settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), - 'OpenID not enabled') - def test_invalid_return_url(self): - """ Test for 403 error code when the url""" - self.attempt_login(403, return_to="http://apps.cs50.edx.or") - - def _send_bad_redirection_login(self): - """ - Attempt to log in to the provider with setup parameters - - Intentionally fail the login to force a redirect - """ - user = UserFactory() - - factory = RequestFactory() - post_params = {'email': user.email, 'password': 'password'} - fake_url = 'fake url' - request = factory.post(reverse('openid-provider-login'), post_params) - openid_setup = { - 'request': factory.request(), - 'url': fake_url, - 'post_params': {} - } - request.session = { - 'openid_setup': openid_setup - } - response = provider_login(request) - return response - - @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID') and - settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), - 'OpenID not enabled') - def test_login_openid_handle_redirection(self): - """ Test to see that we can handle login redirection properly""" - response = self._send_bad_redirection_login() - self.assertEquals(response.status_code, 302) - - @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID') and - settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), - 'OpenID not enabled') - def test_login_openid_handle_redirection_ratelimited(self): - # try logging in 30 times, the default limit in the number of failed - # log in attempts before the rate gets limited - for _ in xrange(30): - self._send_bad_redirection_login() - - response = self._send_bad_redirection_login() - # verify that we are not returning the default 403 - self.assertEquals(response.status_code, 302) - # clear the ratelimit cache so that we don't fail other logins - cache.clear() - - def _attempt_login_and_perform_final_response(self, user, profile_name): - """ - Performs full procedure of a successful OpenID provider login for user, - all required data is taken form ``user`` attribute which is an instance - of ``User`` model. As a convenience this method will also set - ``profile.name`` for the user. - """ - url = reverse('openid-provider-login') - - # login to the client so that we can persist session information - user.profile.name = profile_name - user.profile.save() - # It is asssumed that user's password is test (default for UserFactory) - self.client.login(username=user.username, password='test') - # login once to get the right session information - self.attempt_login(200) - post_args = { - 'email': user.email, - 'password': 'test' - } - - # call url again, this time with username and password - return self.client.post(url, post_args) - - @skipUnless( - settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), 'OpenID not enabled') - def test_provider_login_can_handle_unicode_email(self): - user = UserFactory(email=u"user.ąęł@gmail.com") - resp = self._attempt_login_and_perform_final_response(user, u"Jan ĄĘŁ") - location = resp['Location'] - parsed_url = urlparse(location) - parsed_qs = parse_qs(parsed_url.query) - self.assertEquals(parsed_qs['openid.ax.type.ext1'][0], 'http://axschema.org/contact/email') - self.assertEquals(parsed_qs['openid.ax.type.ext0'][0], 'http://axschema.org/namePerson') - self.assertEquals(parsed_qs['openid.ax.value.ext0.1'][0], - user.profile.name.encode('utf-8')) # pylint: disable=no-member - self.assertEquals(parsed_qs['openid.ax.value.ext1.1'][0], - user.email.encode('utf-8')) # pylint: disable=no-member - - @skipUnless( - settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), 'OpenID not enabled') - def test_provider_login_can_handle_unicode_email_invalid_password(self): - user = UserFactory(email=u"user.ąęł@gmail.com") - url = reverse('openid-provider-login') - - # login to the client so that we can persist session information - user.profile.name = u"Jan ĄĘ" - user.profile.save() - # It is asssumed that user's password is test (default for UserFactory) - self.client.login(username=user.username, password='test') - # login once to get the right session information - self.attempt_login(200) - # We trigger situation where user password is invalid at last phase - # of openid login - post_args = { - 'email': user.email, - 'password': 'invalid-password' - } - - # call url again, this time with username and password - return self.client.post(url, post_args) - - @skipUnless( - settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), 'OpenID not enabled') - def test_provider_login_can_handle_unicode_email_inactive_account(self): - user = UserFactory(email=u"user.ąęł@gmail.com", username=u"ąęół") - url = reverse('openid-provider-login') - - # login to the client so that we can persist session information - user.profile.name = u'Jan ĄĘ' - user.profile.save() # pylint: disable=no-member - self.client.login(username=user.username, password='test') - # login once to get the right session information - self.attempt_login(200) - # We trigger situation where user is not active at final phase of - # OpenId login. - user.is_active = False - user.save() # pylint: disable=no-member - post_args = { - 'email': user.email, - 'password': 'test' - } - # call url again, this time with username and password - self.client.post(url, post_args) - - @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), - 'OpenID not enabled') - def test_openid_final_response(self): - - user = UserFactory() - - # login to the client so that we can persist session information - for name in ['Robot 33', '☃']: - resp = self._attempt_login_and_perform_final_response(user, name) - # all information is embedded in the redirect url - location = resp['Location'] - # parse the url - parsed_url = urlparse(location) - parsed_qs = parse_qs(parsed_url.query) - self.assertEquals(parsed_qs['openid.ax.type.ext1'][0], 'http://axschema.org/contact/email') - self.assertEquals(parsed_qs['openid.ax.type.ext0'][0], 'http://axschema.org/namePerson') - self.assertEquals(parsed_qs['openid.ax.value.ext1.1'][0], user.email) - self.assertEquals(parsed_qs['openid.ax.value.ext0.1'][0], user.profile.name) - - @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), - 'OpenID not enabled') - def test_openid_invalid_password(self): - - url = reverse('openid-provider-login') - user = UserFactory() - - # login to the client so that we can persist session information - for method in ['POST', 'GET']: - self.client.login(username=user.username, password='test') - self.attempt_login(200, method) - openid_setup = self.client.session['openid_setup'] - self.assertIn('post_params', openid_setup) - post_args = { - 'email': user.email, - 'password': 'bad_password', - } - - # call url again, this time with username and password - resp = self.client.post(url, post_args) - self.assertEquals(resp.status_code, 302) - redirect_url = resp['Location'] - parsed_url = urlparse(redirect_url) - query_params = parse_qs(parsed_url[4]) - self.assertIn('openid.return_to', query_params) - self.assertTrue( - query_params['openid.return_to'][0].startswith('http://testserver/openid/complete/') - ) - - -class OpenIdProviderLiveServerTest(LiveServerTestCase): - """ - In order for this absolute URL to work (i.e. to get xrds, then authentication) - in the test environment, we either need a live server that works with the default - fetcher (i.e. urlopen2), or a test server that is reached through a custom fetcher. - Here we do the former. - """ - - @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID') and - settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), - 'OpenID not enabled') - def test_begin_login(self): - # the provider URL must be converted to an absolute URL in order to be - # used as an openid provider. - provider_url = reverse('openid-provider-xrds') - factory = RequestFactory() - request = factory.request() - abs_provider_url = request.build_absolute_uri(location=provider_url) - - # In order for this absolute URL to work (i.e. to get xrds, then authentication) - # in the test environment, we either need a live server that works with the default - # fetcher (i.e. urlopen2), or a test server that is reached through a custom fetcher. - # Here we do the latter: - fetcher = MyFetcher(self.client) - openid.fetchers.setDefaultFetcher(fetcher, wrap_exceptions=False) - - # now we can begin the login process by invoking a local openid client, - # with a pointer to the (also-local) openid provider: - with self.settings(OPENID_SSO_SERVER_URL=abs_provider_url): - url = reverse('openid-login') - resp = self.client.post(url) - code = 200 - self.assertEqual(resp.status_code, code, - "got code {0} for url '{1}'. Expected code {2}" - .format(resp.status_code, url, code)) - - @classmethod - def tearDownClass(cls): - """ - Workaround for a runtime error that occurs - intermittently when the server thread doesn't shut down - within 2 seconds. - - Since the server is running in a Django thread and will - be terminated when the test suite terminates, - this shouldn't cause a resource allocation issue. - """ - try: - super(OpenIdProviderLiveServerTest, cls).tearDownClass() - except RuntimeError: - print "Warning: Could not shut down test server." diff --git a/common/djangoapps/external_auth/tests/test_shib.py b/common/djangoapps/external_auth/tests/test_shib.py deleted file mode 100644 index bfd4511..0000000 --- a/common/djangoapps/external_auth/tests/test_shib.py +++ /dev/null @@ -1,595 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Tests for Shibboleth Authentication -@jbau -""" -import unittest - -from ddt import ddt, data -from django.conf import settings -from django.http import HttpResponseRedirect -from django.test import TestCase -from django.test.client import RequestFactory, Client as DjangoTestClient -from django.test.utils import override_settings -from django.core.urlresolvers import reverse -from django.contrib.auth.models import AnonymousUser, User -from importlib import import_module -from external_auth.models import ExternalAuthMap -from external_auth.views import ( - shib_login, course_specific_login, course_specific_register, _flatten_to_ascii -) -from mock import patch -from nose.plugins.attrib import attr -from urllib import urlencode - -from openedx.core.djangolib.testing.utils import CacheIsolationTestCase -from student.views import change_enrollment -from student.models import UserProfile, CourseEnrollment -from student.tests.factories import UserFactory -from xmodule.modulestore.tests.factories import CourseFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore import ModuleStoreEnum - - -# Shib is supposed to provide 'REMOTE_USER', 'givenName', 'sn', 'mail', 'Shib-Identity-Provider' -# attributes via request.META. We can count on 'Shib-Identity-Provider', and 'REMOTE_USER' being present -# b/c of how mod_shib works but should test the behavior with the rest of the attributes present/missing - -# For the sake of python convention we'll make all of these variable names ALL_CAPS -# These values would all returned from request.META, so they need to be str, not unicode -IDP = 'https://idp.stanford.edu/' -REMOTE_USER = 'test_user@stanford.edu' -MAILS = [None, '', 'test_user@stanford.edu'] # unicode shouldn't be in emails, would fail django's email validator -DISPLAYNAMES = [None, '', 'Jason 包'] -GIVENNAMES = [None, '', 'jasön; John; bob'] # At Stanford, the givenNames can be a list delimited by ';' -SNS = [None, '', '包; smith'] # At Stanford, the sns can be a list delimited by ';' - - -def gen_all_identities(): - """ - A generator for all combinations of test inputs. - Each generated item is a dict that represents what a shib IDP - could potentially pass to django via request.META, i.e. - setting (or not) request.META['givenName'], etc. - """ - def _build_identity_dict(mail, display_name, given_name, surname): - """ Helper function to return a dict of test identity """ - meta_dict = {'Shib-Identity-Provider': IDP, - 'REMOTE_USER': REMOTE_USER} - if display_name is not None: - meta_dict['displayName'] = display_name - if mail is not None: - meta_dict['mail'] = mail - if given_name is not None: - meta_dict['givenName'] = given_name - if surname is not None: - meta_dict['sn'] = surname - return meta_dict - - for mail in MAILS: - for given_name in GIVENNAMES: - for surname in SNS: - for display_name in DISPLAYNAMES: - yield _build_identity_dict(mail, display_name, given_name, surname) - - -@attr(shard=3) -@ddt -@override_settings(SESSION_ENGINE='django.contrib.sessions.backends.cache') -class ShibSPTest(CacheIsolationTestCase): - """ - Tests for the Shibboleth SP, which communicates via request.META - (Apache environment variables set by mod_shib) - """ - - ENABLED_CACHES = ['default'] - - request_factory = RequestFactory() - - def setUp(self): - super(ShibSPTest, self).setUp() - self.test_user_id = ModuleStoreEnum.UserID.test - - @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") - def test_exception_shib_login(self): - """ - Tests that we get the error page when there is no REMOTE_USER - or Shib-Identity-Provider in request.META - """ - no_remote_user_response = self.client.get(reverse('shib-login'), HTTP_SHIB_IDENTITY_PROVIDER=IDP) - self.assertEqual(no_remote_user_response.status_code, 403) - self.assertIn("identity server did not return your ID information", no_remote_user_response.content) - - no_idp_response = self.client.get(reverse('shib-login'), HTTP_REMOTE_USER=REMOTE_USER) - self.assertEqual(no_idp_response.status_code, 403) - self.assertIn("identity server did not return your ID information", no_idp_response.content) - - def _assert_shib_login_is_logged(self, audit_log_call, remote_user): - """Asserts that shibboleth login attempt is being logged""" - remote_user = _flatten_to_ascii(remote_user) # django usernames have to be ascii - method_name, args, _kwargs = audit_log_call - self.assertEquals(method_name, 'info') - self.assertEquals(len(args), 1) - self.assertIn(u'logged in via Shibboleth', args[0]) - self.assertIn(remote_user, args[0]) - - @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") - def test_shib_login(self): - """ - Tests that: - * shib credentials that match an existing ExternalAuthMap with a linked active user logs the user in - * shib credentials that match an existing ExternalAuthMap with a linked inactive user shows error page - * shib credentials that match an existing ExternalAuthMap without a linked user and also match the email - of an existing user without an existing ExternalAuthMap links the two and log the user in - * shib credentials that match an existing ExternalAuthMap without a linked user and also match the email - of an existing user that already has an ExternalAuthMap causes an error (403) - * shib credentials that do not match an existing ExternalAuthMap causes the registration form to appear - """ - - user_w_map = UserFactory.create(email='withmap@stanford.edu') - extauth = ExternalAuthMap(external_id='withmap@stanford.edu', - external_email='', - external_domain='shib:https://idp.stanford.edu/', - external_credentials="", - user=user_w_map) - user_wo_map = UserFactory.create(email='womap@stanford.edu') - user_w_map.save() - user_wo_map.save() - extauth.save() - - inactive_user = UserFactory.create(email='inactive@stanford.edu') - inactive_user.is_active = False - inactive_extauth = ExternalAuthMap(external_id='inactive@stanford.edu', - external_email='', - external_domain='shib:https://idp.stanford.edu/', - external_credentials="", - user=inactive_user) - inactive_user.save() - inactive_extauth.save() - - idps = ['https://idp.stanford.edu/', 'https://someother.idp.com/'] - remote_users = ['withmap@stanford.edu', 'womap@stanford.edu', - 'testuser2@someother_idp.com', 'inactive@stanford.edu'] - - for idp in idps: - for remote_user in remote_users: - - self.client.logout() - with patch('external_auth.views.AUDIT_LOG') as mock_audit_log: - response = self.client.get( - reverse('shib-login'), - **{ - 'Shib-Identity-Provider': idp, - 'mail': remote_user, - 'REMOTE_USER': remote_user, - } - ) - audit_log_calls = mock_audit_log.method_calls - - if idp == "https://idp.stanford.edu/" and remote_user == 'withmap@stanford.edu': - self.assertRedirects(response, '/dashboard') - self.assertEquals(int(self.client.session['_auth_user_id']), user_w_map.id) - # verify logging: - self.assertEquals(len(audit_log_calls), 2) - self._assert_shib_login_is_logged(audit_log_calls[0], remote_user) - method_name, args, _kwargs = audit_log_calls[1] - self.assertEquals(method_name, 'info') - self.assertEquals(len(args), 1) - self.assertIn(u'Login success', args[0]) - self.assertIn(remote_user, args[0]) - elif idp == "https://idp.stanford.edu/" and remote_user == 'inactive@stanford.edu': - self.assertEqual(response.status_code, 403) - self.assertIn("Account not yet activated: please look for link in your email", response.content) - # verify logging: - self.assertEquals(len(audit_log_calls), 2) - self._assert_shib_login_is_logged(audit_log_calls[0], remote_user) - method_name, args, _kwargs = audit_log_calls[1] - self.assertEquals(method_name, 'warning') - self.assertEquals(len(args), 1) - self.assertIn(u'is not active after external login', args[0]) - # self.assertEquals(remote_user, args[1]) - elif idp == "https://idp.stanford.edu/" and remote_user == 'womap@stanford.edu': - self.assertIsNotNone(ExternalAuthMap.objects.get(user=user_wo_map)) - self.assertRedirects(response, '/dashboard') - self.assertEquals(int(self.client.session['_auth_user_id']), user_wo_map.id) - # verify logging: - self.assertEquals(len(audit_log_calls), 2) - self._assert_shib_login_is_logged(audit_log_calls[0], remote_user) - method_name, args, _kwargs = audit_log_calls[1] - self.assertEquals(method_name, 'info') - self.assertEquals(len(args), 1) - self.assertIn(u'Login success', args[0]) - self.assertIn(remote_user, args[0]) - elif idp == "https://someother.idp.com/" and remote_user in \ - ['withmap@stanford.edu', 'womap@stanford.edu', 'inactive@stanford.edu']: - self.assertEqual(response.status_code, 403) - self.assertIn("You have already created an account using an external login", response.content) - # no audit logging calls - self.assertEquals(len(audit_log_calls), 0) - else: - self.assertEqual(response.status_code, 200) - self.assertContains(response, - (u"Preferences for {platform_name}" - .format(platform_name=settings.PLATFORM_NAME))) - # no audit logging calls - self.assertEquals(len(audit_log_calls), 0) - - def _base_test_extauth_auto_activate_user_with_flag(self, log_user_string="inactive@stanford.edu"): - """ - Tests that FEATURES['BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'] means extauth automatically - linked users, activates them, and logs them in - """ - inactive_user = UserFactory.create(email='inactive@stanford.edu') - inactive_user.is_active = False - inactive_user.save() - request = self.request_factory.get('/shib-login') - request.session = import_module(settings.SESSION_ENGINE).SessionStore() # empty session - request.META.update({ - 'Shib-Identity-Provider': 'https://idp.stanford.edu/', - 'REMOTE_USER': 'inactive@stanford.edu', - 'mail': 'inactive@stanford.edu' - }) - - request.user = AnonymousUser() - with patch('external_auth.views.AUDIT_LOG') as mock_audit_log: - response = shib_login(request) - audit_log_calls = mock_audit_log.method_calls - # reload user from db, since the view function works via db side-effects - inactive_user = User.objects.get(id=inactive_user.id) - self.assertIsNotNone(ExternalAuthMap.objects.get(user=inactive_user)) - self.assertTrue(inactive_user.is_active) - self.assertIsInstance(response, HttpResponseRedirect) - self.assertEqual(request.user, inactive_user) - self.assertEqual(response['Location'], '/dashboard') - # verify logging: - self.assertEquals(len(audit_log_calls), 3) - self._assert_shib_login_is_logged(audit_log_calls[0], log_user_string) - method_name, args, _kwargs = audit_log_calls[2] - self.assertEquals(method_name, 'info') - self.assertEquals(len(args), 1) - self.assertIn(u'Login success', args[0]) - self.assertIn(log_user_string, args[0]) - - @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") - @patch.dict(settings.FEATURES, {'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': True, 'SQUELCH_PII_IN_LOGS': False}) - def test_extauth_auto_activate_user_with_flag_no_squelch(self): - """ - Wrapper to run base_test_extauth_auto_activate_user_with_flag with {'SQUELCH_PII_IN_LOGS': False} - """ - self._base_test_extauth_auto_activate_user_with_flag(log_user_string="inactive@stanford.edu") - - @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") - @patch.dict(settings.FEATURES, {'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': True, 'SQUELCH_PII_IN_LOGS': True}) - def test_extauth_auto_activate_user_with_flag_squelch(self): - """ - Wrapper to run base_test_extauth_auto_activate_user_with_flag with {'SQUELCH_PII_IN_LOGS': True} - """ - self._base_test_extauth_auto_activate_user_with_flag(log_user_string="user.id: 1") - - @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") - @data(*gen_all_identities()) - def test_registration_form(self, identity): - """ - Tests the registration form showing up with the proper parameters. - - Uses django test client for its session support - """ - client = DjangoTestClient() - # identity k/v pairs will show up in request.META - response = client.get(path='/shib-login/', data={}, follow=False, **identity) - - self.assertEquals(response.status_code, 200) - mail_input_HTML = '<input class="" id="email" type="email" name="email"' - if not identity.get('mail'): - self.assertContains(response, mail_input_HTML) - else: - self.assertNotContains(response, mail_input_HTML) - sn_empty = not identity.get('sn') - given_name_empty = not identity.get('givenName') - displayname_empty = not identity.get('displayName') - fullname_input_html = '<input id="name" type="text" name="name"' - if sn_empty and given_name_empty and displayname_empty: - self.assertContains(response, fullname_input_html) - else: - self.assertNotContains(response, fullname_input_html) - - @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") - @data(*gen_all_identities()) - def test_registration_form_submit(self, identity): - """ - Tests user creation after the registration form that pops is submitted. If there is no shib - ExternalAuthMap in the session, then the created user should take the username and email from the - request. - - Uses django test client for its session support - """ - # First we pop the registration form - self.client.get(path='/shib-login/', data={}, follow=False, **identity) - # Then we have the user answer the registration form - # These are unicode because request.POST returns unicode - postvars = {'email': u'post_email@stanford.edu', - 'username': u'post_username', # django usernames can't be unicode - 'password': u'post_pássword', - 'name': u'post_náme', - 'terms_of_service': u'true', - 'honor_code': u'true'} - - with patch('student.views.AUDIT_LOG') as mock_audit_log: - self.client.post('/create_account', data=postvars) - - mail = identity.get('mail') - - # verify logging of login happening during account creation: - audit_log_calls = mock_audit_log.method_calls - self.assertEquals(len(audit_log_calls), 3) - method_name, args, _kwargs = audit_log_calls[0] - self.assertEquals(method_name, 'info') - self.assertEquals(len(args), 1) - self.assertIn(u'Login success on new account creation', args[0]) - self.assertIn(u'post_username', args[0]) - method_name, args, _kwargs = audit_log_calls[1] - self.assertEquals(method_name, 'info') - self.assertEquals(len(args), 2) - self.assertIn(u'User registered with external_auth', args[0]) - self.assertEquals(u'post_username', args[1]) - method_name, args, _kwargs = audit_log_calls[2] - self.assertEquals(method_name, 'info') - self.assertEquals(len(args), 3) - self.assertIn(u'Updated ExternalAuthMap for ', args[0]) - self.assertEquals(u'post_username', args[1]) - self.assertEquals(u'test_user@stanford.edu', args[2].external_id) - - user = User.objects.get(id=self.client.session['_auth_user_id']) - - # check that the created user has the right email, either taken from shib or user input - if mail: - self.assertEqual(user.email, mail) - self.assertEqual(list(User.objects.filter(email=postvars['email'])), []) - self.assertIsNotNone(User.objects.get(email=mail)) # get enforces only 1 such user - else: - self.assertEqual(user.email, postvars['email']) - self.assertEqual(list(User.objects.filter(email=mail)), []) - self.assertIsNotNone(User.objects.get(email=postvars['email'])) # get enforces only 1 such user - - # check that the created user profile has the right name, either taken from shib or user input - profile = UserProfile.objects.get(user=user) - sn_empty = not identity.get('sn') - given_name_empty = not identity.get('givenName') - displayname_empty = not identity.get('displayName') - - if displayname_empty: - if sn_empty and given_name_empty: - self.assertEqual(profile.name, postvars['name']) - else: - self.assertEqual(profile.name, self.client.session['ExternalAuthMap'].external_name) - self.assertNotIn(u';', profile.name) - else: - self.assertEqual(profile.name, self.client.session['ExternalAuthMap'].external_name) - self.assertEqual(profile.name, identity.get('displayName').decode('utf-8')) - - -@ddt -@override_settings(SESSION_ENGINE='django.contrib.sessions.backends.cache') -class ShibSPTestModifiedCourseware(ModuleStoreTestCase): - """ - Tests for the Shibboleth SP which modify the courseware - """ - - ENABLED_CACHES = ['default', 'mongo_metadata_inheritance', 'loc_cache'] - - request_factory = RequestFactory() - - def setUp(self): - super(ShibSPTestModifiedCourseware, self).setUp() - self.test_user_id = ModuleStoreEnum.UserID.test - - @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") - @data(None, "", "shib:https://idp.stanford.edu/") - def test_course_specific_login_and_reg(self, domain): - """ - Tests that the correct course specific login and registration urls work for shib - """ - course = CourseFactory.create( - org='MITx', - number='999', - display_name='Robot Super Course', - user_id=self.test_user_id, - ) - - # Test for cases where course is found - # set domains - - # temporarily set the branch to draft-preferred so we can update the course - with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course.id): - course.enrollment_domain = domain - self.store.update_item(course, self.test_user_id) - - # setting location to test that GET params get passed through - login_request = self.request_factory.get('/course_specific_login/MITx/999/Robot_Super_Course' + - '?course_id=MITx/999/Robot_Super_Course' + - '&enrollment_action=enroll') - _reg_request = self.request_factory.get('/course_specific_register/MITx/999/Robot_Super_Course' + - '?course_id=MITx/999/course/Robot_Super_Course' + - '&enrollment_action=enroll') - - login_response = course_specific_login(login_request, 'MITx/999/Robot_Super_Course') - reg_response = course_specific_register(login_request, 'MITx/999/Robot_Super_Course') - - if domain and "shib" in domain: - self.assertIsInstance(login_response, HttpResponseRedirect) - self.assertEqual(login_response['Location'], - reverse('shib-login') + - '?course_id=MITx/999/Robot_Super_Course' + - '&enrollment_action=enroll') - self.assertIsInstance(login_response, HttpResponseRedirect) - self.assertEqual(reg_response['Location'], - reverse('shib-login') + - '?course_id=MITx/999/Robot_Super_Course' + - '&enrollment_action=enroll') - else: - self.assertIsInstance(login_response, HttpResponseRedirect) - self.assertEqual(login_response['Location'], - reverse('signin_user') + - '?course_id=MITx/999/Robot_Super_Course' + - '&enrollment_action=enroll') - self.assertIsInstance(login_response, HttpResponseRedirect) - self.assertEqual(reg_response['Location'], - reverse('register_user') + - '?course_id=MITx/999/Robot_Super_Course' + - '&enrollment_action=enroll') - - # Now test for non-existent course - # setting location to test that GET params get passed through - login_request = self.request_factory.get('/course_specific_login/DNE/DNE/DNE' + - '?course_id=DNE/DNE/DNE' + - '&enrollment_action=enroll') - _reg_request = self.request_factory.get('/course_specific_register/DNE/DNE/DNE' + - '?course_id=DNE/DNE/DNE/Robot_Super_Course' + - '&enrollment_action=enroll') - - login_response = course_specific_login(login_request, 'DNE/DNE/DNE') - reg_response = course_specific_register(login_request, 'DNE/DNE/DNE') - - self.assertIsInstance(login_response, HttpResponseRedirect) - self.assertEqual(login_response['Location'], - reverse('signin_user') + - '?course_id=DNE/DNE/DNE' + - '&enrollment_action=enroll') - self.assertIsInstance(login_response, HttpResponseRedirect) - self.assertEqual(reg_response['Location'], - reverse('register_user') + - '?course_id=DNE/DNE/DNE' + - '&enrollment_action=enroll') - - @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") - def test_enrollment_limit_by_domain(self): - """ - Tests that the enrollmentDomain setting is properly limiting enrollment to those who have - the proper external auth - """ - - # create 2 course, one with limited enrollment one without - shib_course = CourseFactory.create( - org='Stanford', - number='123', - display_name='Shib Only', - enrollment_domain='shib:https://idp.stanford.edu/', - user_id=self.test_user_id, - ) - - open_enroll_course = CourseFactory.create( - org='MITx', - number='999', - display_name='Robot Super Course', - enrollment_domain='', - user_id=self.test_user_id, - ) - - # create 3 kinds of students, external_auth matching shib_course, external_auth not matching, no external auth - shib_student = UserFactory.create() - shib_student.save() - extauth = ExternalAuthMap(external_id='testuser@stanford.edu', - external_email='', - external_domain='shib:https://idp.stanford.edu/', - external_credentials="", - user=shib_student) - extauth.save() - - other_ext_student = UserFactory.create() - other_ext_student.username = "teststudent2" - other_ext_student.email = "teststudent2@other.edu" - other_ext_student.save() - extauth = ExternalAuthMap(external_id='testuser1@other.edu', - external_email='', - external_domain='shib:https://other.edu/', - external_credentials="", - user=other_ext_student) - extauth.save() - - int_student = UserFactory.create() - int_student.username = "teststudent3" - int_student.email = "teststudent3@gmail.com" - int_student.save() - - # Tests the two case for courses, limited and not - for course in [shib_course, open_enroll_course]: - for student in [shib_student, other_ext_student, int_student]: - request = self.request_factory.post('/change_enrollment') - - request.POST.update({'enrollment_action': 'enroll', - 'course_id': course.id.to_deprecated_string()}) - request.user = student - response = change_enrollment(request) - # If course is not limited or student has correct shib extauth then enrollment should be allowed - if course is open_enroll_course or student is shib_student: - self.assertEqual(response.status_code, 200) - self.assertTrue(CourseEnrollment.is_enrolled(student, course.id)) - else: - self.assertEqual(response.status_code, 400) - self.assertFalse(CourseEnrollment.is_enrolled(student, course.id)) - - @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") - def test_shib_login_enrollment(self): - """ - A functionality test that a student with an existing shib login - can auto-enroll in a class with GET or POST params. Also tests the direction functionality of - the 'next' GET/POST param - """ - student = UserFactory.create() - extauth = ExternalAuthMap(external_id='testuser@stanford.edu', - external_email='', - external_domain='shib:https://idp.stanford.edu/', - external_credentials="", - internal_password="password", - user=student) - student.set_password("password") - student.save() - extauth.save() - - course = CourseFactory.create( - org='Stanford', - number='123', - display_name='Shib Only', - enrollment_domain='shib:https://idp.stanford.edu/', - user_id=self.test_user_id, - ) - - # use django test client for sessions and url processing - # no enrollment before trying - self.assertFalse(CourseEnrollment.is_enrolled(student, course.id)) - self.client.logout() - params = [ - ('course_id', course.id.to_deprecated_string()), - ('enrollment_action', 'enroll'), - ('next', '/testredirect') - ] - request_kwargs = {'path': '/shib-login/', - 'data': dict(params), - 'follow': False, - 'REMOTE_USER': 'testuser@stanford.edu', - 'Shib-Identity-Provider': 'https://idp.stanford.edu/'} - response = self.client.get(**request_kwargs) - # successful login is a redirect to the URL that handles auto-enrollment - self.assertEqual(response.status_code, 302) - self.assertEqual(response['location'], 'http://testserver/account/finish_auth?{}'.format(urlencode(params))) - - -class ShibUtilFnTest(TestCase): - """ - Tests util functions in shib module - """ - def test__flatten_to_ascii(self): - DIACRITIC = u"àèìòùÀÈÌÒÙáéíóúýÁÉÍÓÚÝâêîôûÂÊÎÔÛãñõÃÑÕäëïöüÿÄËÏÖÜŸåÅçÇ" # pylint: disable=invalid-name - STR_DIACRI = "àèìòùÀÈÌÒÙáéíóúýÁÉÍÓÚÝâêîôûÂÊÎÔÛãñõÃÑÕäëïöüÿÄËÏÖÜŸåÅçÇ" # pylint: disable=invalid-name - FLATTENED = u"aeiouAEIOUaeiouyAEIOUYaeiouAEIOUanoANOaeiouyAEIOUYaAcC" # pylint: disable=invalid-name - self.assertEqual(_flatten_to_ascii('jasön'), 'jason') # umlaut - self.assertEqual(_flatten_to_ascii('Jason包'), 'Jason') # mandarin, so it just gets dropped - self.assertEqual(_flatten_to_ascii('abc'), 'abc') # pass through - - unicode_test = _flatten_to_ascii(DIACRITIC) - self.assertEqual(unicode_test, FLATTENED) - self.assertIsInstance(unicode_test, unicode) - - str_test = _flatten_to_ascii(STR_DIACRI) - self.assertEqual(str_test, FLATTENED) - self.assertIsInstance(str_test, str) diff --git a/common/djangoapps/external_auth/tests/test_ssl.py b/common/djangoapps/external_auth/tests/test_ssl.py deleted file mode 100644 index 55cfc84..0000000 --- a/common/djangoapps/external_auth/tests/test_ssl.py +++ /dev/null @@ -1,417 +0,0 @@ -""" -Provides unit tests for SSL based authentication portions -of the external_auth app. -""" -import copy -import unittest - -from contextlib import contextmanager -from django.conf import settings -from django.contrib.auth import SESSION_KEY -from django.contrib.auth.models import AnonymousUser, User -from django.contrib.sessions.middleware import SessionMiddleware -from django.core.urlresolvers import reverse -from django.test.client import Client -from django.test.client import RequestFactory -from django.test.utils import override_settings -from external_auth.models import ExternalAuthMap -import external_auth.views -from mock import Mock, patch - -from student.models import CourseEnrollment -from student.roles import CourseStaffRole -from student.tests.factories import UserFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory - -FEATURES_WITH_SSL_AUTH = settings.FEATURES.copy() -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['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['AUTH_USE_CERTIFICATES'] = False -CACHES_ENABLE_GENERAL = copy.deepcopy(settings.CACHES) -CACHES_ENABLE_GENERAL['general']['BACKEND'] = 'django.core.cache.backends.locmem.LocMemCache' - - -@override_settings(FEATURES=FEATURES_WITH_SSL_AUTH) -@override_settings(CACHES=CACHES_ENABLE_GENERAL) -class SSLClientTest(ModuleStoreTestCase): - """ - Tests SSL Authentication code sections of external_auth - """ - - 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 = '/' - - @contextmanager - def _create_ssl_request(self, url): - """Creates a basic request for SSL use.""" - request = self.factory.get(url) - request.META['SSL_CLIENT_S_DN'] = self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL) - request.user = AnonymousUser() - middleware = SessionMiddleware() - middleware.process_request(request) - request.session.save() - - with patch('edxmako.request_context.get_current_request', return_value=request): - yield request - - @contextmanager - 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() - - with patch('edxmako.request_context.get_current_request', return_value=request): - yield 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): - """ - Validate that an SSL login creates an eamap user and - redirects them to the signup page. - """ - with self._create_ssl_request('/') as request: - response = external_auth.views.ssl_login(request) - - # Response should contain template for signup form, eamap should have user, and internal - # auth should not have a user - self.assertIn('<form role="form" id="register-form" method="post"', response.content) - 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))) - - with self.assertRaises(User.DoesNotExist): - User.objects.get(email=self.USER_EMAIL) - - @unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms') - def test_ssl_login_with_signup_cms(self): - """ - Validate that an SSL login creates an eamap user and - redirects them to the signup page on CMS. - """ - self.client.get( - reverse('contentstore.views.login_page'), - SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL) - ) - - 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))) - - with self.assertRaises(User.DoesNotExist): - User.objects.get(email=self.USER_EMAIL) - - @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') - @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP) - def test_ssl_login_without_signup_lms(self): - """ - Test IMMEDIATE_SIGNUP feature flag and ensure the user account is automatically created - and the user is redirected to slash. - """ - with self._create_ssl_request('/') as request: - external_auth.views.ssl_login(request) - - # Assert our user exists in both eamap and Users, and that we are logged in - 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))) - 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))) - - @unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms') - @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP) - def test_ssl_login_without_signup_cms(self): - """ - Test IMMEDIATE_SIGNUP feature flag and ensure the user account is - automatically created on CMS, and that we are redirected - to courses. - """ - - response = self.client.get( - reverse('contentstore.views.login_page'), - SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL) - ) - self.assertEqual(response.status_code, 302) - self.assertIn('/course', response['location']) - - # Assert our user exists in both eamap and Users, and that we are logged in - 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))) - 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))) - - @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') - @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP) - def test_default_login_decorator_ssl(self): - """ - Make sure that SSL login happens if it is enabled on protected - views instead of showing the login form. - """ - response = self.client.get(reverse('dashboard'), follows=True) - self.assertEqual(response.status_code, 302) - self.assertIn(reverse('signin_user'), response['location']) - - 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) - - @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') - @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP) - def test_registration_page_bypass(self): - """ - This tests to make sure when immediate signup is on that - the user doesn't get presented with the registration page. - """ - response = self.client.get( - reverse('register_user'), 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) - - @unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms') - @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP) - def test_cms_registration_page_bypass(self): - """ - This tests to make sure when immediate signup is on that - the user doesn't get presented with the registration page. - """ - response = self.client.get( - reverse('signup'), follow=True, - SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL) - ) - self.assertEqual(response.status_code, 404) - # assert that we are logged in - self.assertIn(SESSION_KEY, self.client.session) - - # Now that we are logged in, make sure we don't see the registration page - response = self.client.get(reverse('signup'), follow=True) - self.assertEqual(response.status_code, 404) - - @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') - @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP) - def test_signin_page_bypass(self): - """ - This tests to make sure when ssl authentication is on - that user doesn't get presented with the login page if they - have a certificate. - """ - # Test that they do signin if they don't have a cert - response = self.client.get(reverse('signin_user')) - self.assertEqual(200, response.status_code) - self.assertIn('login-and-registration-container', response.content) - - # And get directly logged in otherwise - response = self.client.get( - reverse('signin_user'), 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) - - @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. - - The internal password use for certificates has been removed - and this should not fail. - """ - # Create account, break internal password, and activate account - - with self._create_ssl_request('/') as request: - external_auth.views.ssl_login(request) - user = User.objects.get(email=self.USER_EMAIL) - user.set_password('not autogenerated') - user.is_active = True - user.save() - - # Make sure we can still login - self.client.get( - reverse('signin_user'), follow=True, - SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL)) - self.assertIn(SESSION_KEY, self.client.session) - - @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) - - with self._create_normal_request(self.MOCK_URL) as request: - 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 - with self._create_normal_request(self.MOCK_URL) as request: - dec_mock(request) - self.assertTrue(self.mock.called) - self.assertEqual(0, len(ExternalAuthMap.objects.all())) - - # Test valid user - self.mock.reset_mock() - with self._create_ssl_request(self.MOCK_URL) as request: - 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() - with self._create_ssl_request(self.MOCK_URL) as request: - 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) - with self._create_ssl_request(self.MOCK_URL) as request: - 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))) - 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) - - @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') - @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE) - 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' - ) - - with self._create_ssl_request('/') as request: - external_auth.views.ssl_login(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.assertNotIn(SESSION_KEY, 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' - ) - - with self._create_ssl_request('/') as request: - external_auth.views.ssl_login(request) - user = User.objects.get(email=self.USER_EMAIL) - CourseEnrollment.enroll(user, course.id) - - CourseStaffRole(course.id).add_users(user) - course_private_url = reverse('course_handler', args=(unicode(course.id),)) - self.assertNotIn(SESSION_KEY, 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) diff --git a/common/djangoapps/external_auth/views.py b/common/djangoapps/external_auth/views.py deleted file mode 100644 index 795741a..0000000 --- a/common/djangoapps/external_auth/views.py +++ /dev/null @@ -1,947 +0,0 @@ -import functools -import json -import logging -import random -import re -import string -import fnmatch -import unicodedata -import urllib - -from textwrap import dedent -from external_auth.models import ExternalAuthMap -from external_auth.djangostore import DjangoOpenIDStore - -from django.conf import settings -from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login -from django.contrib.auth.models import User -from django.core.urlresolvers import reverse -from django.core.validators import validate_email -from django.core.exceptions import ValidationError - -if settings.FEATURES.get('AUTH_USE_CAS'): - from django_cas.views import login as django_cas_login - -from student.helpers import get_next_url_for_login_page -from student.models import UserProfile - -from django.http import HttpResponse, HttpResponseRedirect, HttpResponseForbidden -from django.utils.http import urlquote, is_safe_url -from django.shortcuts import redirect -from django.utils.translation import ugettext as _ - -from edxmako.shortcuts import render_to_response, render_to_string -try: - from django.views.decorators.csrf import csrf_exempt -except ImportError: - from django.contrib.csrf.middleware import csrf_exempt -from django.views.decorators.csrf import ensure_csrf_cookie - -import django_openid_auth.views as openid_views -from django_openid_auth import auth as openid_auth -from openid.consumer.consumer import SUCCESS - -from openid.server.server import Server, ProtocolError, UntrustedReturnURL -from openid.server.trustroot import TrustRoot -from openid.extensions import ax, sreg -from ratelimitbackend.exceptions import RateLimitException - -import student.views -from xmodule.modulestore.django import modulestore -from opaque_keys.edx.locations import SlashSeparatedCourseKey - -log = logging.getLogger("edx.external_auth") -AUDIT_LOG = logging.getLogger("audit") - -SHIBBOLETH_DOMAIN_PREFIX = settings.SHIBBOLETH_DOMAIN_PREFIX -OPENID_DOMAIN_PREFIX = settings.OPENID_DOMAIN_PREFIX - -# ----------------------------------------------------------------------------- -# OpenID Common -# ----------------------------------------------------------------------------- - - -@csrf_exempt -def default_render_failure(request, - message, - status=403, - template_name='extauth_failure.html', - exception=None): - """Render an Openid error page to the user""" - - log.debug("In openid_failure " + message) - - data = render_to_string(template_name, - dict(message=message, exception=exception)) - - return HttpResponse(data, status=status) - - -# ----------------------------------------------------------------------------- -# OpenID Authentication -# ----------------------------------------------------------------------------- - - -def generate_password(length=12, chars=string.letters + string.digits): - """Generate internal password for externally authenticated user""" - choice = random.SystemRandom().choice - return ''.join([choice(chars) for _i in range(length)]) - - -@csrf_exempt -def openid_login_complete(request, - redirect_field_name=REDIRECT_FIELD_NAME, - render_failure=None): - """Complete the openid login process""" - - render_failure = (render_failure or default_render_failure) - - openid_response = openid_views.parse_openid_response(request) - if not openid_response: - return render_failure(request, - 'This is an OpenID relying party endpoint.') - - if openid_response.status == SUCCESS: - external_id = openid_response.identity_url - oid_backend = openid_auth.OpenIDBackend() - details = oid_backend._extract_user_details(openid_response) - - log.debug('openid success, details=%s', details) - - url = getattr(settings, 'OPENID_SSO_SERVER_URL', None) - external_domain = "{0}{1}".format(OPENID_DOMAIN_PREFIX, url) - fullname = '%s %s' % (details.get('first_name', ''), - details.get('last_name', '')) - - return _external_login_or_signup( - request, - external_id, - external_domain, - details, - details.get('email', ''), - fullname, - retfun=functools.partial(redirect, get_next_url_for_login_page(request)), - ) - - return render_failure(request, 'Openid failure') - - -def _external_login_or_signup(request, - external_id, - external_domain, - credentials, - email, - fullname, - retfun=None): - """Generic external auth login or signup""" - # see if we have a map from this external_id to an edX username - try: - eamap = ExternalAuthMap.objects.get(external_id=external_id, - external_domain=external_domain) - log.debug(u'Found eamap=%s', eamap) - except ExternalAuthMap.DoesNotExist: - # go render form for creating edX user - eamap = ExternalAuthMap(external_id=external_id, - external_domain=external_domain, - external_credentials=json.dumps(credentials)) - eamap.external_email = email - eamap.external_name = fullname - eamap.internal_password = generate_password() - log.debug(u'Created eamap=%s', eamap) - eamap.save() - - log.info(u"External_Auth login_or_signup for %s : %s : %s : %s", external_domain, external_id, email, fullname) - uses_shibboleth = settings.FEATURES.get('AUTH_USE_SHIB') and external_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX) - uses_certs = settings.FEATURES.get('AUTH_USE_CERTIFICATES') - internal_user = eamap.user - if internal_user is None: - if uses_shibboleth: - # If we are using shib, try to link accounts - # For Stanford shib, the email the idp returns is actually under the control of the user. - # Since the id the idps return is not user-editable, and is of the from "username@stanford.edu", - # use the id to link accounts instead. - try: - link_user = User.objects.get(email=eamap.external_id) - if not ExternalAuthMap.objects.filter(user=link_user).exists(): - # if there's no pre-existing linked eamap, we link the user - eamap.user = link_user - eamap.save() - internal_user = link_user - log.info(u'SHIB: Linking existing account for %s', eamap.external_id) - # now pass through to log in - else: - # otherwise, there must have been an error, b/c we've already linked a user with these external - # creds - failure_msg = _( - "You have already created an account using " - "an external login like WebAuth or Shibboleth. " - "Please contact {tech_support_email} for support." - ).format( - tech_support_email=settings.TECH_SUPPORT_EMAIL, - ) - return default_render_failure(request, failure_msg) - except User.DoesNotExist: - log.info(u'SHIB: No user for %s yet, doing signup', eamap.external_email) - return _signup(request, eamap, retfun) - else: - log.info(u'No user for %s yet. doing signup', eamap.external_email) - return _signup(request, eamap, retfun) - - # We trust shib's authentication, so no need to authenticate using the password again - uname = internal_user.username - if uses_shibboleth: - user = internal_user - # Assuming this 'AUTHENTICATION_BACKENDS' is set in settings, which I think is safe - if settings.AUTHENTICATION_BACKENDS: - auth_backend = settings.AUTHENTICATION_BACKENDS[0] - else: - auth_backend = 'ratelimitbackend.backends.RateLimitModelBackend' - user.backend = auth_backend - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.info(u'Linked user.id: {0} logged in via Shibboleth'.format(user.id)) - else: - AUDIT_LOG.info(u'Linked user "{0}" logged in via Shibboleth'.format(user.email)) - elif uses_certs: - # Certificates are trusted, so just link the user and log the action - user = internal_user - user.backend = 'ratelimitbackend.backends.RateLimitModelBackend' - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.info(u'Linked user_id {0} logged in via SSL certificate'.format(user.id)) - else: - AUDIT_LOG.info(u'Linked user "{0}" logged in via SSL certificate'.format(user.email)) - else: - user = authenticate(username=uname, password=eamap.internal_password, request=request) - if user is None: - # we want to log the failure, but don't want to log the password attempted: - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.warning(u'External Auth Login failed') - else: - AUDIT_LOG.warning(u'External Auth Login failed for "{0}"'.format(uname)) - return _signup(request, eamap, retfun) - - if not user.is_active: - if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'): - # if BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH, we trust external auth and activate any users - # that aren't already active - user.is_active = True - user.save() - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.info(u'Activating user {0} due to external auth'.format(user.id)) - else: - AUDIT_LOG.info(u'Activating user "{0}" due to external auth'.format(uname)) - else: - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.warning(u'User {0} is not active after external login'.format(user.id)) - else: - AUDIT_LOG.warning(u'User "{0}" is not active after external login'.format(uname)) - # TODO: improve error page - msg = 'Account not yet activated: please look for link in your email' - return default_render_failure(request, msg) - - login(request, user) - request.session.set_expiry(0) - - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.info(u"Login success - user.id: {0}".format(user.id)) - else: - AUDIT_LOG.info(u"Login success - {0} ({1})".format(user.username, user.email)) - if retfun is None: - return redirect('/') - return retfun() - - -def _flatten_to_ascii(txt): - """ - Flattens possibly unicode txt to ascii (django username limitation) - @param name: - @return: the flattened txt (in the same type as was originally passed in) - """ - if isinstance(txt, str): - txt = txt.decode('utf-8') - return unicodedata.normalize('NFKD', txt).encode('ASCII', 'ignore') - else: - return unicode(unicodedata.normalize('NFKD', txt).encode('ASCII', 'ignore')) - - -@ensure_csrf_cookie -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 - to create an account on the edX system, and fill in the user - registration form. - - 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 - - if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP', ''): - # do signin immediately, by calling create_account, instead of asking - # student to fill in form. MIT students already have information filed. - username = eamap.external_email.split('@', 1)[0] - username = username.replace('.', '_') - post_vars = dict(username=username, - honor_code=u'true', - terms_of_service=u'true') - log.info(u'doing immediate signup for %s, params=%s', username, post_vars) - student.views.create_account(request, post_vars) - # should check return content for successful completion before - if retfun is not None: - return retfun() - else: - return redirect('/') - - # default conjoin name, no spaces, flattened to ascii b/c django can't handle unicode usernames, sadly - # but this only affects username, not fullname - username = re.sub(r'\s', '', _flatten_to_ascii(eamap.external_name), flags=re.UNICODE) - - context = {'has_extauth_info': True, - 'show_signup_immediately': True, - 'extauth_domain': eamap.external_domain, - 'extauth_id': eamap.external_id, - 'extauth_email': eamap.external_email, - 'extauth_username': username, - 'extauth_name': eamap.external_name, - 'ask_for_tos': True, - } - - # Some openEdX instances can't have terms of service for shib users, like - # according to Stanford's Office of General Counsel - uses_shibboleth = (settings.FEATURES.get('AUTH_USE_SHIB') and - eamap.external_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX)) - if uses_shibboleth and settings.FEATURES.get('SHIB_DISABLE_TOS'): - context['ask_for_tos'] = False - - # detect if full name is blank and ask for it from user - context['ask_for_fullname'] = eamap.external_name.strip() == '' - - # validate provided mail and if it's not valid ask the user - try: - validate_email(eamap.external_email) - context['ask_for_email'] = False - except ValidationError: - context['ask_for_email'] = True - - log.info(u'EXTAUTH: Doing signup for %s', eamap.external_id) - - return student.views.register_user(request, extra_context=context) - - -# ----------------------------------------------------------------------------- -# MIT SSL -# ----------------------------------------------------------------------------- - - -def _ssl_dn_extract_info(dn_string): - """ - Extract username, email address (may be anyuser@anydomain.com) and - full name from the SSL DN string. Return (user,email,fullname) if - successful, and None otherwise. - """ - ss = re.search('/emailAddress=(.*)@([^/]+)', dn_string) - if ss: - user = ss.group(1) - email = "%s@%s" % (user, ss.group(2)) - else: - return None - ss = re.search('/CN=([^/]+)/', dn_string) - if ss: - fullname = ss.group(1) - else: - return None - return (user, email, fullname) - - -def ssl_get_cert_from_request(request): - """ - Extract user information from certificate, if it exists, returning (user, email, fullname). - Else return None. - """ - certkey = "SSL_CLIENT_S_DN" # specify the request.META field to use - - cert = request.META.get(certkey, '') - if not cert: - cert = request.META.get('HTTP_' + certkey, '') - if not cert: - try: - # try the direct apache2 SSL key - cert = request._req.subprocess_env.get(certkey, '') - except Exception: - return '' - - return cert - - -def ssl_login_shortcut(fn): - """ - Python function decorator for login procedures, to allow direct login - 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_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, - retfun=retfun - ) - return wrapped - - -@csrf_exempt -def ssl_login(request): - """ - This is called by branding.views.index when - FEATURES['AUTH_USE_CERTIFICATES'] = True - - Used for MIT user authentication. This presumes the web server - (nginx) has been configured to require specific client - certificates. - - If the incoming protocol is HTTPS (SSL) then authenticate via - client certificate. The certificate provides user email and - fullname; this populates the ExternalAuthMap. The user is - nevertheless still asked to complete the edX signup. - - Else continues on with student.views.index, and no authentication. - """ - # Just to make sure we're calling this only at MIT: - if not settings.FEATURES['AUTH_USE_CERTIFICATES']: - return HttpResponseForbidden() - - cert = ssl_get_cert_from_request(request) - - if not cert: - # no certificate information - go onward to main index - return student.views.index(request) - - (_user, email, fullname) = _ssl_dn_extract_info(cert) - - redirect_to = get_next_url_for_login_page(request) - retfun = functools.partial(redirect, redirect_to) - return _external_login_or_signup( - request, - external_id=email, - external_domain="ssl:MIT", - credentials=cert, - email=email, - fullname=fullname, - retfun=retfun - ) - - -# ----------------------------------------------------------------------------- -# CAS (Central Authentication Service) -# ----------------------------------------------------------------------------- -def cas_login(request, next_page=None, required=False): - """ - Uses django_cas for authentication. - CAS is a common authentcation method pioneered by Yale. - See http://en.wikipedia.org/wiki/Central_Authentication_Service - - Does normal CAS login then generates user_profile if nonexistent, - and if login was successful. We assume that user details are - maintained by the central service, and thus an empty user profile - is appropriate. - """ - - ret = django_cas_login(request, next_page, required) - - if request.user.is_authenticated(): - user = request.user - UserProfile.objects.get_or_create( - user=user, - defaults={'name': user.username} - ) - - return ret - - -# ----------------------------------------------------------------------------- -# Shibboleth (Stanford and others. Uses *Apache* environment variables) -# ----------------------------------------------------------------------------- -def shib_login(request): - """ - Uses Apache's REMOTE_USER environment variable as the external id. - This in turn typically uses EduPersonPrincipalName - http://www.incommonfederation.org/attributesummary.html#eduPersonPrincipal - but the configuration is in the shibboleth software. - """ - shib_error_msg = dedent(_( - """ - Your university identity server did not return your ID information to us. - Please try logging in again. (You may need to restart your browser.) - """)) - - if not request.META.get('REMOTE_USER'): - log.error(u"SHIB: no REMOTE_USER found in request.META") - return default_render_failure(request, shib_error_msg) - elif not request.META.get('Shib-Identity-Provider'): - log.error(u"SHIB: no Shib-Identity-Provider in request.META") - return default_render_failure(request, shib_error_msg) - else: - # If we get here, the user has authenticated properly - shib = {attr: request.META.get(attr, '').decode('utf-8') - for attr in ['REMOTE_USER', 'givenName', 'sn', 'mail', 'Shib-Identity-Provider', 'displayName']} - - # Clean up first name, last name, and email address - # TODO: Make this less hardcoded re: format, but split will work - # even if ";" is not present, since we are accessing 1st element - shib['sn'] = shib['sn'].split(";")[0].strip().capitalize() - shib['givenName'] = shib['givenName'].split(";")[0].strip().capitalize() - - # TODO: should we be logging creds here, at info level? - log.info(u"SHIB creds returned: %r", shib) - - fullname = shib['displayName'] if shib['displayName'] else u'%s %s' % (shib['givenName'], shib['sn']) - - redirect_to = get_next_url_for_login_page(request) - retfun = functools.partial(_safe_postlogin_redirect, redirect_to, request.get_host()) - - return _external_login_or_signup( - request, - external_id=shib['REMOTE_USER'], - external_domain=SHIBBOLETH_DOMAIN_PREFIX + shib['Shib-Identity-Provider'], - credentials=shib, - email=shib['mail'], - fullname=fullname, - retfun=retfun - ) - - -def _safe_postlogin_redirect(redirect_to, safehost, default_redirect='/'): - """ - If redirect_to param is safe (not off this host), then perform the redirect. - Otherwise just redirect to '/'. - Basically copied from django.contrib.auth.views.login - @param redirect_to: user-supplied redirect url - @param safehost: which host is safe to redirect to - @return: an HttpResponseRedirect - """ - if is_safe_url(url=redirect_to, host=safehost): - return redirect(redirect_to) - return redirect(default_redirect) - - -def course_specific_login(request, course_id): - """ - Dispatcher function for selecting the specific login method - required by the course - """ - course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) - course = modulestore().get_course(course_key) - if not course: - # couldn't find the course, will just return vanilla signin page - return redirect_with_get('signin_user', request.GET) - - # now the dispatching conditionals. Only shib for now - if ( - settings.FEATURES.get('AUTH_USE_SHIB') and - course.enrollment_domain and - course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX) - ): - return redirect_with_get('shib-login', request.GET) - - # Default fallthrough to normal signin page - return redirect_with_get('signin_user', request.GET) - - -def course_specific_register(request, course_id): - """ - Dispatcher function for selecting the specific registration method - required by the course - """ - course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) - course = modulestore().get_course(course_key) - - if not course: - # couldn't find the course, will just return vanilla registration page - return redirect_with_get('register_user', request.GET) - - # now the dispatching conditionals. Only shib for now - if ( - settings.FEATURES.get('AUTH_USE_SHIB') and - course.enrollment_domain and - course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX) - ): - # shib-login takes care of both registration and login flows - return redirect_with_get('shib-login', request.GET) - - # Default fallthrough to normal registration page - return redirect_with_get('register_user', request.GET) - - -def redirect_with_get(view_name, get_querydict, do_reverse=True): - """ - Helper function to carry over get parameters across redirects - 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: - return redirect("%s?%s" % (url, get_querydict.urlencode(safe='/'))) - return redirect(view_name) - - -# ----------------------------------------------------------------------------- -# OpenID Provider -# ----------------------------------------------------------------------------- - - -def get_xrds_url(resource, request): - """ - Return the XRDS url for a resource - """ - host = request.get_host() - - location = host + '/openid/provider/' + resource + '/' - - if request.is_secure(): - return 'https://' + location - else: - return 'http://' + location - - -def add_openid_simple_registration(request, response, data): - sreg_data = {} - sreg_request = sreg.SRegRequest.fromOpenIDRequest(request) - sreg_fields = sreg_request.allRequestedFields() - - # if consumer requested simple registration fields, add them - if sreg_fields: - for field in sreg_fields: - if field == 'email' and 'email' in data: - sreg_data['email'] = data['email'] - elif field == 'fullname' and 'fullname' in data: - sreg_data['fullname'] = data['fullname'] - elif field == 'nickname' and 'nickname' in data: - sreg_data['nickname'] = data['nickname'] - - # construct sreg response - sreg_response = sreg.SRegResponse.extractResponse(sreg_request, - sreg_data) - sreg_response.toMessage(response.fields) - - -def add_openid_attribute_exchange(request, response, data): - try: - ax_request = ax.FetchRequest.fromOpenIDRequest(request) - except ax.AXError: - # not using OpenID attribute exchange extension - pass - else: - ax_response = ax.FetchResponse() - - # if consumer requested attribute exchange fields, add them - if ax_request and ax_request.requested_attributes: - for type_uri in ax_request.requested_attributes.iterkeys(): - email_schema = 'http://axschema.org/contact/email' - name_schema = 'http://axschema.org/namePerson' - if type_uri == email_schema and 'email' in data: - ax_response.addValue(email_schema, data['email']) - elif type_uri == name_schema and 'fullname' in data: - ax_response.addValue(name_schema, data['fullname']) - - # construct ax response - ax_response.toMessage(response.fields) - - -def provider_respond(server, request, response, data): - """ - Respond to an OpenID request - """ - # get and add extensions - add_openid_simple_registration(request, response, data) - add_openid_attribute_exchange(request, response, data) - - # create http response from OpenID response - webresponse = server.encodeResponse(response) - http_response = HttpResponse(webresponse.body) - http_response.status_code = webresponse.code - - # add OpenID headers to response - for k, v in webresponse.headers.iteritems(): - http_response[k] = v - - return http_response - - -def validate_trust_root(openid_request): - """ - Only allow OpenID requests from valid trust roots - """ - - trusted_roots = getattr(settings, 'OPENID_PROVIDER_TRUSTED_ROOT', None) - - if not trusted_roots: - # not using trusted roots - return True - - # don't allow empty trust roots - if (not hasattr(openid_request, 'trust_root') or - not openid_request.trust_root): - log.error('no trust_root') - return False - - # ensure trust root parses cleanly (one wildcard, of form *.foo.com, etc.) - trust_root = TrustRoot.parse(openid_request.trust_root) - if not trust_root: - log.error('invalid trust_root') - return False - - # don't allow empty return tos - if (not hasattr(openid_request, 'return_to') or - not openid_request.return_to): - log.error('empty return_to') - return False - - # ensure return to is within trust root - if not trust_root.validateURL(openid_request.return_to): - log.error('invalid return_to') - return False - - # check that the root matches the ones we trust - if not any(r for r in trusted_roots if fnmatch.fnmatch(trust_root, r)): - log.error('non-trusted root') - return False - - return True - - -@csrf_exempt -def provider_login(request): - """ - OpenID login endpoint - """ - - # make and validate endpoint - endpoint = get_xrds_url('login', request) - if not endpoint: - return default_render_failure(request, "Invalid OpenID request") - - # initialize store and server - store = DjangoOpenIDStore() - server = Server(store, endpoint) - - # first check to see if the request is an OpenID request. - # If so, the client will have specified an 'openid.mode' as part - # of the request. - if request.method == 'GET': - querydict = dict(request.GET.items()) - else: - querydict = dict(request.POST.items()) - error = False - if 'openid.mode' in request.GET or 'openid.mode' in request.POST: - # decode request - try: - openid_request = server.decodeRequest(querydict) - except (UntrustedReturnURL, ProtocolError): - openid_request = None - - if not openid_request: - return default_render_failure(request, "Invalid OpenID request") - - # don't allow invalid and non-trusted trust roots - if not validate_trust_root(openid_request): - return default_render_failure(request, "Invalid OpenID trust root") - - # checkid_immediate not supported, require user interaction - if openid_request.mode == 'checkid_immediate': - return provider_respond(server, openid_request, - openid_request.answer(False), {}) - - # checkid_setup, so display login page - # (by falling through to the provider_login at the - # bottom of this method). - elif openid_request.mode == 'checkid_setup': - if openid_request.idSelect(): - # remember request and original path - request.session['openid_setup'] = { - 'request': openid_request, - 'url': request.get_full_path(), - 'post_params': request.POST, - } - - # user failed login on previous attempt - if 'openid_error' in request.session: - error = True - del request.session['openid_error'] - - # OpenID response - else: - return provider_respond(server, openid_request, - server.handleRequest(openid_request), {}) - - # handle login redirection: these are also sent to this view function, - # but are distinguished by lacking the openid mode. We also know that - # they are posts, because they come from the popup - elif request.method == 'POST' and 'openid_setup' in request.session: - # get OpenID request from session - openid_setup = request.session['openid_setup'] - openid_request = openid_setup['request'] - openid_request_url = openid_setup['url'] - post_params = openid_setup['post_params'] - # We need to preserve the parameters, and the easiest way to do this is - # through the URL - url_post_params = { - param: post_params[param] for param in post_params if param.startswith('openid') - } - - encoded_params = urllib.urlencode(url_post_params) - - if '?' not in openid_request_url: - openid_request_url = openid_request_url + '?' + encoded_params - else: - openid_request_url = openid_request_url + '&' + encoded_params - - del request.session['openid_setup'] - - # don't allow invalid trust roots - if not validate_trust_root(openid_request): - return default_render_failure(request, "Invalid OpenID trust root") - - # check if user with given email exists - # Failure is redirected to this method (by using the original URL), - # which will bring up the login dialog. - email = request.POST.get('email', None) - try: - user = User.objects.get(email=email) - except User.DoesNotExist: - request.session['openid_error'] = True - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.warning(u"OpenID login failed - Unknown user email") - else: - msg = u"OpenID login failed - Unknown user email: {0}".format(email) - AUDIT_LOG.warning(msg) - return HttpResponseRedirect(openid_request_url) - - # attempt to authenticate user (but not actually log them in...) - # Failure is again redirected to the login dialog. - username = user.username - password = request.POST.get('password', None) - try: - user = authenticate(username=username, password=password, request=request) - except RateLimitException: - AUDIT_LOG.warning(u'OpenID - Too many failed login attempts.') - return HttpResponseRedirect(openid_request_url) - - if user is None: - request.session['openid_error'] = True - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.warning(u"OpenID login failed - invalid password") - else: - AUDIT_LOG.warning( - u"OpenID login failed - password for %s is invalid", email) - return HttpResponseRedirect(openid_request_url) - - # authentication succeeded, so fetch user information - # that was requested - if user is not None and user.is_active: - # remove error from session since login succeeded - if 'openid_error' in request.session: - del request.session['openid_error'] - - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.info(u"OpenID login success - user.id: %s", user.id) - else: - AUDIT_LOG.info( - u"OpenID login success - %s (%s)", user.username, user.email) - # redirect user to return_to location - url = endpoint + urlquote(user.username) - response = openid_request.answer(True, None, url) - - # Note too that this is hardcoded, and not really responding to - # the extensions that were registered in the first place. - results = { - 'nickname': user.username, - 'email': user.email, - 'fullname': user.profile.name, - } - - # the request succeeded: - return provider_respond(server, openid_request, response, results) - - # the account is not active, so redirect back to the login page: - request.session['openid_error'] = True - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.warning( - u"Login failed - Account not active for user.id %s", user.id) - else: - AUDIT_LOG.warning( - u"Login failed - Account not active for user %s", username) - return HttpResponseRedirect(openid_request_url) - - # determine consumer domain if applicable - return_to = request.GET.get('openid.return_to') or request.POST.get('openid.return_to') or '' - if return_to: - matches = re.match(r'\w+:\/\/([\w\.-]+)', return_to) - return_to = matches.group(1) - - # display login page - response = render_to_response('provider_login.html', { - 'error': error, - 'return_to': return_to - }) - - # add custom XRDS header necessary for discovery process - response['X-XRDS-Location'] = get_xrds_url('xrds', request) - return response - - -def provider_identity(request): - """ - XRDS for identity discovery - """ - - response = render_to_response('identity.xml', - {'url': get_xrds_url('login', request)}, - content_type='text/xml') - - # custom XRDS header necessary for discovery process - response['X-XRDS-Location'] = get_xrds_url('identity', request) - return response - - -def provider_xrds(request): - """ - XRDS for endpoint discovery - """ - - response = render_to_response('xrds.xml', - {'url': get_xrds_url('login', request)}, - content_type='text/xml') - - # custom XRDS header necessary for discovery process - response['X-XRDS-Location'] = get_xrds_url('xrds', request) - return response diff --git a/common/djangoapps/student/tests/test_create_account.py b/common/djangoapps/student/tests/test_create_account.py index 7a2958d..656e9cd 100644 --- a/common/djangoapps/student/tests/test_create_account.py +++ b/common/djangoapps/student/tests/test_create_account.py @@ -16,7 +16,7 @@ import mock from openedx.core.djangoapps.user_api.preferences.api import get_user_preference from lang_pref import LANGUAGE_KEY from notification_prefs import NOTIFICATION_PREF_KEY -from external_auth.models import ExternalAuthMap +from openedx.core.djangoapps.external_auth.models import ExternalAuthMap import student from student.models import UserAttribute from student.views import REGISTRATION_AFFILIATE_ID diff --git a/common/djangoapps/student/tests/test_login.py b/common/djangoapps/student/tests/test_login.py index 4ce14fd..3699289 100644 --- a/common/djangoapps/student/tests/test_login.py +++ b/common/djangoapps/student/tests/test_login.py @@ -16,7 +16,7 @@ import httpretty from mock import patch from social.apps.django_app.default.models import UserSocialAuth -from external_auth.models import ExternalAuthMap +from openedx.core.djangoapps.external_auth.models import ExternalAuthMap from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from student.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory from student.views import login_oauth_token diff --git a/common/djangoapps/student/tests/test_password_policy.py b/common/djangoapps/student/tests/test_password_policy.py index 2c8fe1b..2eae69f 100644 --- a/common/djangoapps/student/tests/test_password_policy.py +++ b/common/djangoapps/student/tests/test_password_policy.py @@ -11,7 +11,7 @@ from importlib import import_module from django.test.utils import override_settings from django.conf import settings from mock import patch -from external_auth.models import ExternalAuthMap +from openedx.core.djangoapps.external_auth.models import ExternalAuthMap from student.views import create_account diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index e63b527..198d55e 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -78,9 +78,9 @@ from courseware.access import has_access from django_comment_common.models import Role -from external_auth.models import ExternalAuthMap -import external_auth.views -from external_auth.login_and_register import ( +from openedx.core.djangoapps.external_auth.models import ExternalAuthMap +import openedx.core.djangoapps.external_auth.views +from openedx.core.djangoapps.external_auth.login_and_register import ( login as external_auth_login, register as external_auth_register ) @@ -470,7 +470,9 @@ def register_user(request, extra_context=None): if extra_context is not None: context.update(extra_context) - if context.get("extauth_domain", '').startswith(external_auth.views.SHIBBOLETH_DOMAIN_PREFIX): + if context.get("extauth_domain", '').startswith( + openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX + ): return render_to_response('register-shib.html', context) # If third-party auth is enabled, prepopulate the form with data from the @@ -1195,7 +1197,7 @@ def login_user(request, error=""): # pylint: disable=too-many-statements,unused if settings.FEATURES.get('AUTH_USE_SHIB') and user: try: eamap = ExternalAuthMap.objects.get(user=user) - if eamap.external_domain.startswith(external_auth.views.SHIBBOLETH_DOMAIN_PREFIX): + if eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX): return JsonResponse({ "success": False, "redirect": reverse('shib-login'), @@ -1637,9 +1639,7 @@ def create_account_with_params(request, params): not settings.FEATURES.get("AUTH_USE_SHIB") or not settings.FEATURES.get("SHIB_DISABLE_TOS") or not do_external_auth or - not eamap.external_domain.startswith( - external_auth.views.SHIBBOLETH_DOMAIN_PREFIX - ) + not eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX) ) form = AccountCreationForm( diff --git a/docs/en_us/platform_api/source/conf.py b/docs/en_us/platform_api/source/conf.py index 7f79a33..807b193 100644 --- a/docs/en_us/platform_api/source/conf.py +++ b/docs/en_us/platform_api/source/conf.py @@ -101,7 +101,7 @@ MOCK_MODULES = [ 'openid', 'openid.store', 'openid.store.interface', - 'external_auth.views', + 'openedx.core.djangoapps.external_auth.views', 'mail_utils', 'ratelimitbackend.backends', 'social.apps.django_app.default', diff --git a/lms/djangoapps/branding/views.py b/lms/djangoapps/branding/views.py index e6c21c5..c32b931 100644 --- a/lms/djangoapps/branding/views.py +++ b/lms/djangoapps/branding/views.py @@ -61,7 +61,7 @@ def index(request): return redirect(reverse('dashboard')) if settings.FEATURES.get('AUTH_USE_CERTIFICATES'): - from external_auth.views import ssl_login + from openedx.core.djangoapps.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'): diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index d458403..43776a3 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -33,7 +33,7 @@ from xmodule.x_module import XModule from xmodule.split_test_module import get_split_user_partitions from xmodule.partitions.partitions import NoSuchUserPartitionError, NoSuchUserPartitionGroupError -from external_auth.models import ExternalAuthMap +from openedx.core.djangoapps.external_auth.models import ExternalAuthMap from courseware.masquerade import get_masquerade_role, is_masquerading_as_student from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from student import auth diff --git a/lms/djangoapps/dashboard/sysadmin.py b/lms/djangoapps/dashboard/sysadmin.py index 5ef36f7..dd6e320 100644 --- a/lms/djangoapps/dashboard/sysadmin.py +++ b/lms/djangoapps/dashboard/sysadmin.py @@ -34,8 +34,8 @@ import dashboard.git_import as git_import from dashboard.git_import import GitImportError from student.roles import CourseStaffRole, CourseInstructorRole from dashboard.models import CourseImportLog -from external_auth.models import ExternalAuthMap -from external_auth.views import generate_password +from openedx.core.djangoapps.external_auth.models import ExternalAuthMap +from openedx.core.djangoapps.external_auth.views import generate_password from student.models import CourseEnrollment, UserProfile, Registration import track.views from xmodule.modulestore.django import modulestore diff --git a/lms/djangoapps/lms_migration/management/commands/create_user.py b/lms/djangoapps/lms_migration/management/commands/create_user.py index 03ebff0..4f70c49 100644 --- a/lms/djangoapps/lms_migration/management/commands/create_user.py +++ b/lms/djangoapps/lms_migration/management/commands/create_user.py @@ -15,7 +15,7 @@ import readline from django.core.management.base import BaseCommand from student.models import UserProfile, Registration -from external_auth.models import ExternalAuthMap +from openedx.core.djangoapps.external_auth.models import ExternalAuthMap from django.contrib.auth.models import User, Group from pytz import UTC diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index 645f573..05629ea 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -22,7 +22,7 @@ from edxmako.shortcuts import render_to_response import pytz from commerce.models import CommerceConfiguration -from external_auth.login_and_register import ( +from openedx.core.djangoapps.external_auth.login_and_register import ( login as external_auth_login, register as external_auth_register ) diff --git a/lms/envs/common.py b/lms/envs/common.py index 8d6c33d..a193d09 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1917,7 +1917,7 @@ INSTALLED_APPS = ( 'support', # External auth (OpenID, shib) - 'external_auth', + 'openedx.core.djangoapps.external_auth', 'django_openid_auth', # django-oauth2-provider (deprecated) diff --git a/lms/urls.py b/lms/urls.py index 2e165ec..69ae6b4 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -803,27 +803,31 @@ if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'): if settings.FEATURES.get('AUTH_USE_OPENID'): urlpatterns += ( url(r'^openid/login/$', 'django_openid_auth.views.login_begin', name='openid-login'), - url(r'^openid/complete/$', 'external_auth.views.openid_login_complete', name='openid-complete'), + url( + r'^openid/complete/$', + 'openedx.core.djangoapps.external_auth.views.openid_login_complete', + name='openid-complete', + ), url(r'^openid/logo.gif$', 'django_openid_auth.views.logo', name='openid-logo'), ) if settings.FEATURES.get('AUTH_USE_SHIB'): urlpatterns += ( - url(r'^shib-login/$', 'external_auth.views.shib_login', name='shib-login'), + url(r'^shib-login/$', 'openedx.core.djangoapps.external_auth.views.shib_login', name='shib-login'), ) if settings.FEATURES.get('AUTH_USE_CAS'): urlpatterns += ( - url(r'^cas-auth/login/$', 'external_auth.views.cas_login', name="cas-login"), + url(r'^cas-auth/login/$', 'openedx.core.djangoapps.external_auth.views.cas_login', name="cas-login"), url(r'^cas-auth/logout/$', 'django_cas.views.logout', {'next_page': '/'}, name="cas-logout"), ) if settings.FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD'): urlpatterns += ( url(r'^course_specific_login/{}/$'.format(settings.COURSE_ID_PATTERN), - 'external_auth.views.course_specific_login', name='course-specific-login'), + 'openedx.core.djangoapps.external_auth.views.course_specific_login', name='course-specific-login'), url(r'^course_specific_register/{}/$'.format(settings.COURSE_ID_PATTERN), - 'external_auth.views.course_specific_register', name='course-specific-register'), + 'openedx.core.djangoapps.external_auth.views.course_specific_register', name='course-specific-register'), ) @@ -846,14 +850,26 @@ urlpatterns += ( if settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'): urlpatterns += ( - url(r'^openid/provider/login/$', 'external_auth.views.provider_login', name='openid-provider-login'), + url( + r'^openid/provider/login/$', + 'openedx.core.djangoapps.external_auth.views.provider_login', + name='openid-provider-login', + ), url( r'^openid/provider/login/(?:.+)$', - 'external_auth.views.provider_identity', + 'openedx.core.djangoapps.external_auth.views.provider_identity', name='openid-provider-login-identity' ), - url(r'^openid/provider/identity/$', 'external_auth.views.provider_identity', name='openid-provider-identity'), - url(r'^openid/provider/xrds/$', 'external_auth.views.provider_xrds', name='openid-provider-xrds') + url( + r'^openid/provider/identity/$', + 'openedx.core.djangoapps.external_auth.views.provider_identity', + name='openid-provider-identity', + ), + url( + r'^openid/provider/xrds/$', + 'openedx.core.djangoapps.external_auth.views.provider_xrds', + name='openid-provider-xrds', + ), ) if settings.FEATURES.get('ENABLE_OAUTH2_PROVIDER'): diff --git a/openedx/core/djangoapps/external_auth/__init__.py b/openedx/core/djangoapps/external_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/openedx/core/djangoapps/external_auth/__init__.py diff --git a/openedx/core/djangoapps/external_auth/admin.py b/openedx/core/djangoapps/external_auth/admin.py new file mode 100644 index 0000000..d6278e2 --- /dev/null +++ b/openedx/core/djangoapps/external_auth/admin.py @@ -0,0 +1,16 @@ +''' +django admin pages for courseware model +''' + +from openedx.core.djangoapps.external_auth.models import ExternalAuthMap +from ratelimitbackend import admin + + +class ExternalAuthMapAdmin(admin.ModelAdmin): + """ + Admin model for ExternalAuthMap + """ + search_fields = ['external_id', 'user__username'] + date_hierarchy = 'dtcreated' + +admin.site.register(ExternalAuthMap, ExternalAuthMapAdmin) diff --git a/openedx/core/djangoapps/external_auth/djangostore.py b/openedx/core/djangoapps/external_auth/djangostore.py new file mode 100644 index 0000000..0e16f77 --- /dev/null +++ b/openedx/core/djangoapps/external_auth/djangostore.py @@ -0,0 +1,132 @@ +"""A openid store using django cache""" + +from openid.store.interface import OpenIDStore +from openid.store import nonce + +from django.core.cache import cache + +import logging +import time + +DEFAULT_ASSOCIATIONS_TIMEOUT = 60 +DEFAULT_NONCE_TIMEOUT = 600 + +ASSOCIATIONS_KEY_PREFIX = 'openid.provider.associations.' +NONCE_KEY_PREFIX = 'openid.provider.nonce.' + +log = logging.getLogger('DjangoOpenIDStore') + + +def get_url_key(server_url): + """ + Returns the URL key for the given server_url. + """ + return ASSOCIATIONS_KEY_PREFIX + server_url + + +def get_nonce_key(server_url, timestamp, salt): + """ + Returns the nonce for the given parameters. + """ + return '{prefix}{url}.{ts}.{salt}'.format( + prefix=NONCE_KEY_PREFIX, + url=server_url, + ts=timestamp, + salt=salt, + ) + + +class DjangoOpenIDStore(OpenIDStore): + """ + django implementation of OpenIDStore. + """ + def __init__(self): + log.info('DjangoStore cache:' + str(cache.__class__)) + + def storeAssociation(self, server_url, assoc): + key = get_url_key(server_url) + + log.info('storeAssociation {0}'.format(key)) + + associations = cache.get(key, {}) + associations[assoc.handle] = assoc + + cache.set(key, associations, DEFAULT_ASSOCIATIONS_TIMEOUT) + + def getAssociation(self, server_url, handle=None): + key = get_url_key(server_url) + + log.info('getAssociation {0}'.format(key)) + + associations = cache.get(key, {}) + + assoc = None + + if handle is None: + # get best association + valid_assocs = [a for a in associations if a.getExpiresIn() > 0] + if valid_assocs: + valid_assocs.sort(lambda a: a.getExpiresIn(), reverse=True) + assoc = valid_assocs.sort[0] + else: + assoc = associations.get(handle) + + # check expiration and remove if it has expired + if assoc and assoc.getExpiresIn() <= 0: + if handle is None: + cache.delete(key) + else: + associations.pop(handle) + cache.set(key, associations, DEFAULT_ASSOCIATIONS_TIMEOUT) + assoc = None + + return assoc + + def removeAssociation(self, server_url, handle): + key = get_url_key(server_url) + + log.info('removeAssociation {0}'.format(key)) + + associations = cache.get(key, {}) + + removed = False + + if associations: + if handle is None: + cache.delete(key) + removed = True + else: + assoc = associations.pop(handle, None) + if assoc: + cache.set(key, associations, DEFAULT_ASSOCIATIONS_TIMEOUT) + removed = True + + return removed + + def useNonce(self, server_url, timestamp, salt): + key = get_nonce_key(server_url, timestamp, salt) + + log.info('useNonce {0}'.format(key)) + + if abs(timestamp - time.time()) > nonce.SKEW: + return False + + anonce = cache.get(key) + + found = False + + if anonce is None: + cache.set(key, '-', DEFAULT_NONCE_TIMEOUT) + found = False + else: + found = True + + return found + + def cleanupNonces(self): + # not necesary, keys will timeout + return 0 + + def cleanupAssociations(self): + # not necesary, keys will timeout + return 0 diff --git a/openedx/core/djangoapps/external_auth/login_and_register.py b/openedx/core/djangoapps/external_auth/login_and_register.py new file mode 100644 index 0000000..20a4f80 --- /dev/null +++ b/openedx/core/djangoapps/external_auth/login_and_register.py @@ -0,0 +1,98 @@ +"""Intercept login and registration requests. + +This module contains legacy code originally from `student.views`. +""" +import re + +from django.conf import settings +from django.shortcuts import redirect +from django.core.urlresolvers import reverse +import openedx.core.djangoapps.external_auth.views + +from xmodule.modulestore.django import modulestore +from opaque_keys.edx.keys import CourseKey + + +# pylint: disable=fixme +# TODO: This function is kind of gnarly/hackish/etc and is only used in one location. +# It'd be awesome if we could get rid of it; manually parsing course_id strings form larger strings +# seems Probably Incorrect +def _parse_course_id_from_string(input_str): + """ + Helper function to determine if input_str (typically the queryparam 'next') contains a course_id. + @param input_str: + @return: the course_id if found, None if not + """ + m_obj = re.match(r'^/courses/{}'.format(settings.COURSE_ID_PATTERN), input_str) + if m_obj: + return CourseKey.from_string(m_obj.group('course_id')) + return None + + +def _get_course_enrollment_domain(course_id): + """ + Helper function to get the enrollment domain set for a course with id course_id + @param course_id: + @return: + """ + course = modulestore().get_course(course_id) + if course is None: + return None + + return course.enrollment_domain + + +def login(request): + """Allow external auth to intercept and handle a login request. + + Arguments: + request (Request): A request for the login page. + + Returns: + Response or None + + """ + # Default to a `None` response, indicating that external auth + # is not handling the request. + response = None + + if ( + settings.FEATURES['AUTH_USE_CERTIFICATES'] and + openedx.core.djangoapps.external_auth.views.ssl_get_cert_from_request(request) + ): + # SSL login doesn't require a view, so redirect + # branding and allow that to process the login if it + # is enabled and the header is in the request. + response = openedx.core.djangoapps.external_auth.views.redirect_with_get('root', request.GET) + elif settings.FEATURES.get('AUTH_USE_CAS'): + # If CAS is enabled, redirect auth handling to there + response = redirect(reverse('cas-login')) + elif settings.FEATURES.get('AUTH_USE_SHIB'): + redirect_to = request.GET.get('next') + if redirect_to: + course_id = _parse_course_id_from_string(redirect_to) + if course_id and _get_course_enrollment_domain(course_id): + response = openedx.core.djangoapps.external_auth.views.course_specific_login( + request, + course_id.to_deprecated_string(), + ) + + return response + + +def register(request): + """Allow external auth to intercept and handle a registration request. + + Arguments: + request (Request): A request for the registration page. + + Returns: + Response or None + + """ + response = None + if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'): + # Redirect to branding to process their certificate if SSL is enabled + # and registration is disabled. + response = openedx.core.djangoapps.external_auth.views.redirect_with_get('root', request.GET) + return response diff --git a/openedx/core/djangoapps/external_auth/migrations/0001_initial.py b/openedx/core/djangoapps/external_auth/migrations/0001_initial.py new file mode 100644 index 0000000..f68b8f0 --- /dev/null +++ b/openedx/core/djangoapps/external_auth/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ExternalAuthMap', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('external_id', models.CharField(max_length=255, db_index=True)), + ('external_domain', models.CharField(max_length=255, db_index=True)), + ('external_credentials', models.TextField(blank=True)), + ('external_email', models.CharField(max_length=255, db_index=True)), + ('external_name', models.CharField(db_index=True, max_length=255, blank=True)), + ('internal_password', models.CharField(max_length=31, blank=True)), + ('dtcreated', models.DateTimeField(auto_now_add=True, verbose_name=b'creation date')), + ('dtsignup', models.DateTimeField(null=True, verbose_name=b'signup date')), + ('user', models.OneToOneField(null=True, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AlterUniqueTogether( + name='externalauthmap', + unique_together=set([('external_id', 'external_domain')]), + ), + ] diff --git a/openedx/core/djangoapps/external_auth/migrations/__init__.py b/openedx/core/djangoapps/external_auth/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/openedx/core/djangoapps/external_auth/migrations/__init__.py diff --git a/openedx/core/djangoapps/external_auth/models.py b/openedx/core/djangoapps/external_auth/models.py new file mode 100644 index 0000000..03e076e --- /dev/null +++ b/openedx/core/djangoapps/external_auth/models.py @@ -0,0 +1,35 @@ +""" +WE'RE USING MIGRATIONS! + +If you make changes to this model, be sure to create an appropriate migration +file and check it in at the same time as your model changes. To do that, + +1. Go to the edx-platform dir +2. ./manage.py lms schemamigration student --auto description_of_your_change +3. Add the migration file created in edx-platform/openedx/core/djangoapps/external_auth/migrations/ +""" + +from django.db import models +from django.contrib.auth.models import User + + +class ExternalAuthMap(models.Model): + """ + Model class for external auth. + """ + class Meta(object): + app_label = "external_auth" + unique_together = (('external_id', 'external_domain'), ) + + external_id = models.CharField(max_length=255, db_index=True) + external_domain = models.CharField(max_length=255, db_index=True) + external_credentials = models.TextField(blank=True) # JSON dictionary + external_email = models.CharField(max_length=255, db_index=True) + external_name = models.CharField(blank=True, max_length=255, db_index=True) + user = models.OneToOneField(User, unique=True, db_index=True, null=True) + internal_password = models.CharField(blank=True, max_length=31) # randomly generated + dtcreated = models.DateTimeField('creation date', auto_now_add=True) + dtsignup = models.DateTimeField('signup date', null=True) # set after signup + + def __unicode__(self): + return "[%s] = (%s / %s)" % (self.external_id, self.external_name, self.external_email) diff --git a/openedx/core/djangoapps/external_auth/tests/__init__.py b/openedx/core/djangoapps/external_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/openedx/core/djangoapps/external_auth/tests/__init__.py diff --git a/openedx/core/djangoapps/external_auth/tests/test_helper.py b/openedx/core/djangoapps/external_auth/tests/test_helper.py new file mode 100644 index 0000000..b028d62 --- /dev/null +++ b/openedx/core/djangoapps/external_auth/tests/test_helper.py @@ -0,0 +1,29 @@ +""" +Tests for utility functions in external_auth module +""" +from django.test import TestCase +from openedx.core.djangoapps.external_auth.views import _safe_postlogin_redirect + + +class ExternalAuthHelperFnTest(TestCase): + """ + Unit tests for the external_auth.views helper function + """ + def test__safe_postlogin_redirect(self): + """ + Tests the _safe_postlogin_redirect function with different values of next + """ + HOST = 'testserver' # pylint: disable=invalid-name + ONSITE1 = '/dashboard' # pylint: disable=invalid-name + ONSITE2 = '/courses/org/num/name/courseware' # pylint: disable=invalid-name + ONSITE3 = 'http://{}/my/custom/url'.format(HOST) # pylint: disable=invalid-name + OFFSITE1 = 'http://www.attacker.com' # pylint: disable=invalid-name + + for redirect_to in [ONSITE1, ONSITE2, ONSITE3]: + redir = _safe_postlogin_redirect(redirect_to, HOST) + self.assertEqual(redir.status_code, 302) + self.assertEqual(redir['location'], redirect_to) + + redir2 = _safe_postlogin_redirect(OFFSITE1, HOST) + self.assertEqual(redir2.status_code, 302) + self.assertEqual("/", redir2['location']) diff --git a/openedx/core/djangoapps/external_auth/tests/test_openid_provider.py b/openedx/core/djangoapps/external_auth/tests/test_openid_provider.py new file mode 100644 index 0000000..174715b --- /dev/null +++ b/openedx/core/djangoapps/external_auth/tests/test_openid_provider.py @@ -0,0 +1,473 @@ +#-*- encoding=utf-8 -*- +''' +Created on Jan 18, 2013 + +@author: brian +''' +import openid +from openid.fetchers import HTTPFetcher, HTTPResponse +from urlparse import parse_qs, urlparse + +from django.conf import settings +from django.test import TestCase, LiveServerTestCase +from django.core.cache import cache +from django.test.utils import override_settings +from django.core.urlresolvers import reverse +from django.test.client import RequestFactory +from unittest import skipUnless + +from student.tests.factories import UserFactory +from openedx.core.djangoapps.external_auth.views import provider_login + + +class MyFetcher(HTTPFetcher): + """A fetcher that uses server-internal calls for performing HTTP + requests. + """ + + def __init__(self, client): + """@param client: A test client object""" + + super(MyFetcher, self).__init__() + self.client = client + + def fetch(self, url, body=None, headers=None): + """Perform an HTTP request + + @raises Exception: Any exception that can be raised by Django + + @see: C{L{HTTPFetcher.fetch}} + """ + if body: + # method = 'POST' + # undo the URL encoding of the POST arguments + data = parse_qs(body) + response = self.client.post(url, data) + else: + # method = 'GET' + data = {} + if headers and 'Accept' in headers: + data['CONTENT_TYPE'] = headers['Accept'] + response = self.client.get(url, data) + + # Translate the test client response to the fetcher's HTTP response abstraction + content = response.content + final_url = url + response_headers = {} + if 'Content-Type' in response: + response_headers['content-type'] = response['Content-Type'] + if 'X-XRDS-Location' in response: + response_headers['x-xrds-location'] = response['X-XRDS-Location'] + status = response.status_code + + return HTTPResponse( + body=content, + final_url=final_url, + headers=response_headers, + status=status, + ) + + +class OpenIdProviderTest(TestCase): + """ + Tests of the OpenId login + """ + @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID') and + settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), + 'OpenID not enabled') + def test_begin_login_with_xrds_url(self): + + # the provider URL must be converted to an absolute URL in order to be + # used as an openid provider. + provider_url = reverse('openid-provider-xrds') + factory = RequestFactory() + request = factory.request() + abs_provider_url = request.build_absolute_uri(location=provider_url) + + # In order for this absolute URL to work (i.e. to get xrds, then authentication) + # in the test environment, we either need a live server that works with the default + # fetcher (i.e. urlopen2), or a test server that is reached through a custom fetcher. + # Here we do the latter: + fetcher = MyFetcher(self.client) + openid.fetchers.setDefaultFetcher(fetcher, wrap_exceptions=False) + + # now we can begin the login process by invoking a local openid client, + # with a pointer to the (also-local) openid provider: + with self.settings(OPENID_SSO_SERVER_URL=abs_provider_url): + + url = reverse('openid-login') + resp = self.client.post(url) + code = 200 + self.assertEqual(resp.status_code, code, + "got code {0} for url '{1}'. Expected code {2}" + .format(resp.status_code, url, code)) + + @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID') and + settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), + 'OpenID not enabled') + def test_begin_login_with_login_url(self): + + # the provider URL must be converted to an absolute URL in order to be + # used as an openid provider. + provider_url = reverse('openid-provider-login') + factory = RequestFactory() + request = factory.request() + abs_provider_url = request.build_absolute_uri(location=provider_url) + + # In order for this absolute URL to work (i.e. to get xrds, then authentication) + # in the test environment, we either need a live server that works with the default + # fetcher (i.e. urlopen2), or a test server that is reached through a custom fetcher. + # Here we do the latter: + fetcher = MyFetcher(self.client) + openid.fetchers.setDefaultFetcher(fetcher, wrap_exceptions=False) + + # now we can begin the login process by invoking a local openid client, + # with a pointer to the (also-local) openid provider: + with self.settings(OPENID_SSO_SERVER_URL=abs_provider_url): + url = reverse('openid-login') + resp = self.client.post(url) + code = 200 + self.assertEqual(resp.status_code, code, + "got code {0} for url '{1}'. Expected code {2}" + .format(resp.status_code, url, code)) + for expected_input in ( + '<input name="openid.ns" type="hidden" value="http://specs.openid.net/auth/2.0" />', + + '<input name="openid.ns.ax" type="hidden" value="http://openid.net/srv/ax/1.0" />', + + '<input name="openid.ax.type.fullname" type="hidden" value="http://axschema.org/namePerson" />', + + '<input type="submit" value="Continue" />', + + '<input name="openid.ax.type.email" type="hidden" value="http://axschema.org/contact/email" />', + + '<input name="openid.ax.type.lastname" ' + 'type="hidden" value="http://axschema.org/namePerson/last" />', + + '<input name="openid.ax.type.firstname" ' + 'type="hidden" value="http://axschema.org/namePerson/first" />', + + '<input name="openid.ax.required" type="hidden" ' + 'value="email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname" />', + + '<input name="openid.ax.type.nickname" ' + 'type="hidden" value="http://axschema.org/namePerson/friendly" />', + + '<input name="openid.ax.type.old_email" ' + 'type="hidden" value="http://schema.openid.net/contact/email" />', + + '<input name="openid.ax.type.old_nickname" ' + 'type="hidden" value="http://schema.openid.net/namePerson/friendly" />', + + '<input name="openid.ax.type.old_fullname" ' + 'type="hidden" value="http://schema.openid.net/namePerson" />', + + '<input name="openid.identity" ' + 'type="hidden" value="http://specs.openid.net/auth/2.0/identifier_select" />', + + '<input name="openid.claimed_id" ' + 'type="hidden" value="http://specs.openid.net/auth/2.0/identifier_select" />', + + # should work on the test server as well + '<input name="openid.realm" ' + 'type="hidden" value="http://testserver/" />', + ): + self.assertContains(resp, expected_input, html=True) + + # not included here are elements that will vary from run to run: + # <input name="openid.return_to" type="hidden" + # value="http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H" /> + # <input name="openid.assoc_handle" type="hidden" value="{HMAC-SHA1}{50ff8120}{rh87+Q==}" /> + + def attempt_login(self, expected_code, login_method='POST', **kwargs): + """ Attempt to log in through the open id provider login """ + url = reverse('openid-provider-login') + args = { + "openid.mode": "checkid_setup", + "openid.return_to": "http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H", + "openid.assoc_handle": "{HMAC-SHA1}{50ff8120}{rh87+Q==}", + "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select", + "openid.ns": "http://specs.openid.net/auth/2.0", + "openid.realm": "http://testserver/", + "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select", + "openid.ns.ax": "http://openid.net/srv/ax/1.0", + "openid.ax.mode": "fetch_request", + "openid.ax.required": "email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname", + "openid.ax.type.fullname": "http://axschema.org/namePerson", + "openid.ax.type.lastname": "http://axschema.org/namePerson/last", + "openid.ax.type.firstname": "http://axschema.org/namePerson/first", + "openid.ax.type.nickname": "http://axschema.org/namePerson/friendly", + "openid.ax.type.email": "http://axschema.org/contact/email", + "openid.ax.type.old_email": "http://schema.openid.net/contact/email", + "openid.ax.type.old_nickname": "http://schema.openid.net/namePerson/friendly", + "openid.ax.type.old_fullname": "http://schema.openid.net/namePerson", + } + # override the default args with any given arguments + for key in kwargs: + args["openid." + key] = kwargs[key] + + if login_method == 'POST': + resp = self.client.post(url, args) + elif login_method == 'GET': + resp = self.client.get(url, args) + else: + self.fail('Invalid login method') + + code = expected_code + self.assertEqual(resp.status_code, code, + "got code {0} for url '{1}'. Expected code {2}" + .format(resp.status_code, url, code)) + + @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID') and + settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), + 'OpenID not enabled') + def test_open_id_setup(self): + """ Attempt a standard successful login """ + self.attempt_login(200) + + @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID') and + settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), + 'OpenID not enabled') + def test_invalid_namespace(self): + """ Test for 403 error code when the namespace of the request is invalid""" + self.attempt_login(403, ns="http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0") + + @override_settings(OPENID_PROVIDER_TRUSTED_ROOTS=['http://apps.cs50.edx.org']) + @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID') and + settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), + 'OpenID not enabled') + def test_invalid_return_url(self): + """ Test for 403 error code when the url""" + self.attempt_login(403, return_to="http://apps.cs50.edx.or") + + def _send_bad_redirection_login(self): + """ + Attempt to log in to the provider with setup parameters + + Intentionally fail the login to force a redirect + """ + user = UserFactory() + + factory = RequestFactory() + post_params = {'email': user.email, 'password': 'password'} + fake_url = 'fake url' + request = factory.post(reverse('openid-provider-login'), post_params) + openid_setup = { + 'request': factory.request(), + 'url': fake_url, + 'post_params': {} + } + request.session = { + 'openid_setup': openid_setup + } + response = provider_login(request) + return response + + @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID') and + settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), + 'OpenID not enabled') + def test_login_openid_handle_redirection(self): + """ Test to see that we can handle login redirection properly""" + response = self._send_bad_redirection_login() + self.assertEquals(response.status_code, 302) + + @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID') and + settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), + 'OpenID not enabled') + def test_login_openid_handle_redirection_ratelimited(self): + # try logging in 30 times, the default limit in the number of failed + # log in attempts before the rate gets limited + for _ in xrange(30): + self._send_bad_redirection_login() + + response = self._send_bad_redirection_login() + # verify that we are not returning the default 403 + self.assertEquals(response.status_code, 302) + # clear the ratelimit cache so that we don't fail other logins + cache.clear() + + def _attempt_login_and_perform_final_response(self, user, profile_name): + """ + Performs full procedure of a successful OpenID provider login for user, + all required data is taken form ``user`` attribute which is an instance + of ``User`` model. As a convenience this method will also set + ``profile.name`` for the user. + """ + url = reverse('openid-provider-login') + + # login to the client so that we can persist session information + user.profile.name = profile_name + user.profile.save() + # It is asssumed that user's password is test (default for UserFactory) + self.client.login(username=user.username, password='test') + # login once to get the right session information + self.attempt_login(200) + post_args = { + 'email': user.email, + 'password': 'test' + } + + # call url again, this time with username and password + return self.client.post(url, post_args) + + @skipUnless( + settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), 'OpenID not enabled') + def test_provider_login_can_handle_unicode_email(self): + user = UserFactory(email=u"user.ąęł@gmail.com") + resp = self._attempt_login_and_perform_final_response(user, u"Jan ĄĘŁ") + location = resp['Location'] + parsed_url = urlparse(location) + parsed_qs = parse_qs(parsed_url.query) + self.assertEquals(parsed_qs['openid.ax.type.ext1'][0], 'http://axschema.org/contact/email') + self.assertEquals(parsed_qs['openid.ax.type.ext0'][0], 'http://axschema.org/namePerson') + self.assertEquals(parsed_qs['openid.ax.value.ext0.1'][0], + user.profile.name.encode('utf-8')) # pylint: disable=no-member + self.assertEquals(parsed_qs['openid.ax.value.ext1.1'][0], + user.email.encode('utf-8')) # pylint: disable=no-member + + @skipUnless( + settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), 'OpenID not enabled') + def test_provider_login_can_handle_unicode_email_invalid_password(self): + user = UserFactory(email=u"user.ąęł@gmail.com") + url = reverse('openid-provider-login') + + # login to the client so that we can persist session information + user.profile.name = u"Jan ĄĘ" + user.profile.save() + # It is asssumed that user's password is test (default for UserFactory) + self.client.login(username=user.username, password='test') + # login once to get the right session information + self.attempt_login(200) + # We trigger situation where user password is invalid at last phase + # of openid login + post_args = { + 'email': user.email, + 'password': 'invalid-password' + } + + # call url again, this time with username and password + return self.client.post(url, post_args) + + @skipUnless( + settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), 'OpenID not enabled') + def test_provider_login_can_handle_unicode_email_inactive_account(self): + user = UserFactory(email=u"user.ąęł@gmail.com", username=u"ąęół") + url = reverse('openid-provider-login') + + # login to the client so that we can persist session information + user.profile.name = u'Jan ĄĘ' + user.profile.save() # pylint: disable=no-member + self.client.login(username=user.username, password='test') + # login once to get the right session information + self.attempt_login(200) + # We trigger situation where user is not active at final phase of + # OpenId login. + user.is_active = False + user.save() # pylint: disable=no-member + post_args = { + 'email': user.email, + 'password': 'test' + } + # call url again, this time with username and password + self.client.post(url, post_args) + + @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), + 'OpenID not enabled') + def test_openid_final_response(self): + + user = UserFactory() + + # login to the client so that we can persist session information + for name in ['Robot 33', '☃']: + resp = self._attempt_login_and_perform_final_response(user, name) + # all information is embedded in the redirect url + location = resp['Location'] + # parse the url + parsed_url = urlparse(location) + parsed_qs = parse_qs(parsed_url.query) + self.assertEquals(parsed_qs['openid.ax.type.ext1'][0], 'http://axschema.org/contact/email') + self.assertEquals(parsed_qs['openid.ax.type.ext0'][0], 'http://axschema.org/namePerson') + self.assertEquals(parsed_qs['openid.ax.value.ext1.1'][0], user.email) + self.assertEquals(parsed_qs['openid.ax.value.ext0.1'][0], user.profile.name) + + @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), + 'OpenID not enabled') + def test_openid_invalid_password(self): + + url = reverse('openid-provider-login') + user = UserFactory() + + # login to the client so that we can persist session information + for method in ['POST', 'GET']: + self.client.login(username=user.username, password='test') + self.attempt_login(200, method) + openid_setup = self.client.session['openid_setup'] + self.assertIn('post_params', openid_setup) + post_args = { + 'email': user.email, + 'password': 'bad_password', + } + + # call url again, this time with username and password + resp = self.client.post(url, post_args) + self.assertEquals(resp.status_code, 302) + redirect_url = resp['Location'] + parsed_url = urlparse(redirect_url) + query_params = parse_qs(parsed_url[4]) + self.assertIn('openid.return_to', query_params) + self.assertTrue( + query_params['openid.return_to'][0].startswith('http://testserver/openid/complete/') + ) + + +class OpenIdProviderLiveServerTest(LiveServerTestCase): + """ + In order for this absolute URL to work (i.e. to get xrds, then authentication) + in the test environment, we either need a live server that works with the default + fetcher (i.e. urlopen2), or a test server that is reached through a custom fetcher. + Here we do the former. + """ + + @skipUnless(settings.FEATURES.get('AUTH_USE_OPENID') and + settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'), + 'OpenID not enabled') + def test_begin_login(self): + # the provider URL must be converted to an absolute URL in order to be + # used as an openid provider. + provider_url = reverse('openid-provider-xrds') + factory = RequestFactory() + request = factory.request() + abs_provider_url = request.build_absolute_uri(location=provider_url) + + # In order for this absolute URL to work (i.e. to get xrds, then authentication) + # in the test environment, we either need a live server that works with the default + # fetcher (i.e. urlopen2), or a test server that is reached through a custom fetcher. + # Here we do the latter: + fetcher = MyFetcher(self.client) + openid.fetchers.setDefaultFetcher(fetcher, wrap_exceptions=False) + + # now we can begin the login process by invoking a local openid client, + # with a pointer to the (also-local) openid provider: + with self.settings(OPENID_SSO_SERVER_URL=abs_provider_url): + url = reverse('openid-login') + resp = self.client.post(url) + code = 200 + self.assertEqual(resp.status_code, code, + "got code {0} for url '{1}'. Expected code {2}" + .format(resp.status_code, url, code)) + + @classmethod + def tearDownClass(cls): + """ + Workaround for a runtime error that occurs + intermittently when the server thread doesn't shut down + within 2 seconds. + + Since the server is running in a Django thread and will + be terminated when the test suite terminates, + this shouldn't cause a resource allocation issue. + """ + try: + super(OpenIdProviderLiveServerTest, cls).tearDownClass() + except RuntimeError: + print "Warning: Could not shut down test server." diff --git a/openedx/core/djangoapps/external_auth/tests/test_shib.py b/openedx/core/djangoapps/external_auth/tests/test_shib.py new file mode 100644 index 0000000..59510ff --- /dev/null +++ b/openedx/core/djangoapps/external_auth/tests/test_shib.py @@ -0,0 +1,597 @@ +# -*- coding: utf-8 -*- +#pylint: disable=no-member +""" +Tests for Shibboleth Authentication +@jbau +""" +import unittest + +from ddt import ddt, data +from django.conf import settings +from django.http import HttpResponseRedirect +from django.test import TestCase +from django.test.client import RequestFactory, Client as DjangoTestClient +from django.test.utils import override_settings +from django.core.urlresolvers import reverse +from django.contrib.auth.models import AnonymousUser, User +from importlib import import_module +from openedx.core.djangoapps.external_auth.models import ExternalAuthMap +from openedx.core.djangoapps.external_auth.views import ( + shib_login, course_specific_login, course_specific_register, _flatten_to_ascii +) +from mock import patch +from nose.plugins.attrib import attr +from urllib import urlencode + +from openedx.core.djangolib.testing.utils import CacheIsolationTestCase +from student.views import change_enrollment +from student.models import UserProfile, CourseEnrollment +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore import ModuleStoreEnum + + +# Shib is supposed to provide 'REMOTE_USER', 'givenName', 'sn', 'mail', 'Shib-Identity-Provider' +# attributes via request.META. We can count on 'Shib-Identity-Provider', and 'REMOTE_USER' being present +# b/c of how mod_shib works but should test the behavior with the rest of the attributes present/missing + +# For the sake of python convention we'll make all of these variable names ALL_CAPS +# These values would all returned from request.META, so they need to be str, not unicode +IDP = 'https://idp.stanford.edu/' +REMOTE_USER = 'test_user@stanford.edu' +MAILS = [None, '', 'test_user@stanford.edu'] # unicode shouldn't be in emails, would fail django's email validator +DISPLAYNAMES = [None, '', 'Jason 包'] +GIVENNAMES = [None, '', 'jasön; John; bob'] # At Stanford, the givenNames can be a list delimited by ';' +SNS = [None, '', '包; smith'] # At Stanford, the sns can be a list delimited by ';' + + +def gen_all_identities(): + """ + A generator for all combinations of test inputs. + Each generated item is a dict that represents what a shib IDP + could potentially pass to django via request.META, i.e. + setting (or not) request.META['givenName'], etc. + """ + def _build_identity_dict(mail, display_name, given_name, surname): + """ Helper function to return a dict of test identity """ + meta_dict = {'Shib-Identity-Provider': IDP, + 'REMOTE_USER': REMOTE_USER} + if display_name is not None: + meta_dict['displayName'] = display_name + if mail is not None: + meta_dict['mail'] = mail + if given_name is not None: + meta_dict['givenName'] = given_name + if surname is not None: + meta_dict['sn'] = surname + return meta_dict + + for mail in MAILS: + for given_name in GIVENNAMES: + for surname in SNS: + for display_name in DISPLAYNAMES: + yield _build_identity_dict(mail, display_name, given_name, surname) + + +@attr(shard=3) +@ddt +@override_settings(SESSION_ENGINE='django.contrib.sessions.backends.cache') +class ShibSPTest(CacheIsolationTestCase): + """ + Tests for the Shibboleth SP, which communicates via request.META + (Apache environment variables set by mod_shib) + """ + + ENABLED_CACHES = ['default'] + + request_factory = RequestFactory() + + def setUp(self): + super(ShibSPTest, self).setUp() + self.test_user_id = ModuleStoreEnum.UserID.test + + @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") + def test_exception_shib_login(self): + """ + Tests that we get the error page when there is no REMOTE_USER + or Shib-Identity-Provider in request.META + """ + no_remote_user_response = self.client.get(reverse('shib-login'), HTTP_SHIB_IDENTITY_PROVIDER=IDP) + self.assertEqual(no_remote_user_response.status_code, 403) + self.assertIn("identity server did not return your ID information", no_remote_user_response.content) + + no_idp_response = self.client.get(reverse('shib-login'), HTTP_REMOTE_USER=REMOTE_USER) + self.assertEqual(no_idp_response.status_code, 403) + self.assertIn("identity server did not return your ID information", no_idp_response.content) + + def _assert_shib_login_is_logged(self, audit_log_call, remote_user): + """Asserts that shibboleth login attempt is being logged""" + remote_user = _flatten_to_ascii(remote_user) # django usernames have to be ascii + method_name, args, _kwargs = audit_log_call + self.assertEquals(method_name, 'info') + self.assertEquals(len(args), 1) + self.assertIn(u'logged in via Shibboleth', args[0]) + self.assertIn(remote_user, args[0]) + + @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") + def test_shib_login(self): + """ + Tests that: + * shib credentials that match an existing ExternalAuthMap with a linked active user logs the user in + * shib credentials that match an existing ExternalAuthMap with a linked inactive user shows error page + * shib credentials that match an existing ExternalAuthMap without a linked user and also match the email + of an existing user without an existing ExternalAuthMap links the two and log the user in + * shib credentials that match an existing ExternalAuthMap without a linked user and also match the email + of an existing user that already has an ExternalAuthMap causes an error (403) + * shib credentials that do not match an existing ExternalAuthMap causes the registration form to appear + """ + # pylint: disable=too-many-statements + + user_w_map = UserFactory.create(email='withmap@stanford.edu') + extauth = ExternalAuthMap(external_id='withmap@stanford.edu', + external_email='', + external_domain='shib:https://idp.stanford.edu/', + external_credentials="", + user=user_w_map) + user_wo_map = UserFactory.create(email='womap@stanford.edu') + user_w_map.save() + user_wo_map.save() + extauth.save() + + inactive_user = UserFactory.create(email='inactive@stanford.edu') + inactive_user.is_active = False + inactive_extauth = ExternalAuthMap(external_id='inactive@stanford.edu', + external_email='', + external_domain='shib:https://idp.stanford.edu/', + external_credentials="", + user=inactive_user) + inactive_user.save() + inactive_extauth.save() + + idps = ['https://idp.stanford.edu/', 'https://someother.idp.com/'] + remote_users = ['withmap@stanford.edu', 'womap@stanford.edu', + 'testuser2@someother_idp.com', 'inactive@stanford.edu'] + + for idp in idps: + for remote_user in remote_users: + + self.client.logout() + with patch('openedx.core.djangoapps.external_auth.views.AUDIT_LOG') as mock_audit_log: + response = self.client.get( + reverse('shib-login'), + **{ + 'Shib-Identity-Provider': idp, + 'mail': remote_user, + 'REMOTE_USER': remote_user, + } + ) + audit_log_calls = mock_audit_log.method_calls + + if idp == "https://idp.stanford.edu/" and remote_user == 'withmap@stanford.edu': + self.assertRedirects(response, '/dashboard') + self.assertEquals(int(self.client.session['_auth_user_id']), user_w_map.id) + # verify logging: + self.assertEquals(len(audit_log_calls), 2) + self._assert_shib_login_is_logged(audit_log_calls[0], remote_user) + method_name, args, _kwargs = audit_log_calls[1] + self.assertEquals(method_name, 'info') + self.assertEquals(len(args), 1) + self.assertIn(u'Login success', args[0]) + self.assertIn(remote_user, args[0]) + elif idp == "https://idp.stanford.edu/" and remote_user == 'inactive@stanford.edu': + self.assertEqual(response.status_code, 403) + self.assertIn("Account not yet activated: please look for link in your email", response.content) + # verify logging: + self.assertEquals(len(audit_log_calls), 2) + self._assert_shib_login_is_logged(audit_log_calls[0], remote_user) + method_name, args, _kwargs = audit_log_calls[1] + self.assertEquals(method_name, 'warning') + self.assertEquals(len(args), 1) + self.assertIn(u'is not active after external login', args[0]) + # self.assertEquals(remote_user, args[1]) + elif idp == "https://idp.stanford.edu/" and remote_user == 'womap@stanford.edu': + self.assertIsNotNone(ExternalAuthMap.objects.get(user=user_wo_map)) + self.assertRedirects(response, '/dashboard') + self.assertEquals(int(self.client.session['_auth_user_id']), user_wo_map.id) + # verify logging: + self.assertEquals(len(audit_log_calls), 2) + self._assert_shib_login_is_logged(audit_log_calls[0], remote_user) + method_name, args, _kwargs = audit_log_calls[1] + self.assertEquals(method_name, 'info') + self.assertEquals(len(args), 1) + self.assertIn(u'Login success', args[0]) + self.assertIn(remote_user, args[0]) + elif idp == "https://someother.idp.com/" and remote_user in \ + ['withmap@stanford.edu', 'womap@stanford.edu', 'inactive@stanford.edu']: + self.assertEqual(response.status_code, 403) + self.assertIn("You have already created an account using an external login", response.content) + # no audit logging calls + self.assertEquals(len(audit_log_calls), 0) + else: + self.assertEqual(response.status_code, 200) + self.assertContains(response, + (u"Preferences for {platform_name}" + .format(platform_name=settings.PLATFORM_NAME))) + # no audit logging calls + self.assertEquals(len(audit_log_calls), 0) + + def _test_auto_activate_user_with_flag(self, log_user_string="inactive@stanford.edu"): + """ + Tests that FEATURES['BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'] means extauth automatically + linked users, activates them, and logs them in + """ + inactive_user = UserFactory.create(email='inactive@stanford.edu') + inactive_user.is_active = False + inactive_user.save() + request = self.request_factory.get('/shib-login') + request.session = import_module(settings.SESSION_ENGINE).SessionStore() # empty session + request.META.update({ + 'Shib-Identity-Provider': 'https://idp.stanford.edu/', + 'REMOTE_USER': 'inactive@stanford.edu', + 'mail': 'inactive@stanford.edu' + }) + + request.user = AnonymousUser() + with patch('openedx.core.djangoapps.external_auth.views.AUDIT_LOG') as mock_audit_log: + response = shib_login(request) + audit_log_calls = mock_audit_log.method_calls + # reload user from db, since the view function works via db side-effects + inactive_user = User.objects.get(id=inactive_user.id) + self.assertIsNotNone(ExternalAuthMap.objects.get(user=inactive_user)) + self.assertTrue(inactive_user.is_active) + self.assertIsInstance(response, HttpResponseRedirect) + self.assertEqual(request.user, inactive_user) + self.assertEqual(response['Location'], '/dashboard') + # verify logging: + self.assertEquals(len(audit_log_calls), 3) + self._assert_shib_login_is_logged(audit_log_calls[0], log_user_string) + method_name, args, _kwargs = audit_log_calls[2] + self.assertEquals(method_name, 'info') + self.assertEquals(len(args), 1) + self.assertIn(u'Login success', args[0]) + self.assertIn(log_user_string, args[0]) + + @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") + @patch.dict(settings.FEATURES, {'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': True, 'SQUELCH_PII_IN_LOGS': False}) + def test_extauth_auto_activate_user_with_flag_no_squelch(self): + """ + Wrapper to run base_test_extauth_auto_activate_user_with_flag with {'SQUELCH_PII_IN_LOGS': False} + """ + self._test_auto_activate_user_with_flag(log_user_string="inactive@stanford.edu") + + @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") + @patch.dict(settings.FEATURES, {'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': True, 'SQUELCH_PII_IN_LOGS': True}) + def test_extauth_auto_activate_user_with_flag_squelch(self): + """ + Wrapper to run base_test_extauth_auto_activate_user_with_flag with {'SQUELCH_PII_IN_LOGS': True} + """ + self._test_auto_activate_user_with_flag(log_user_string="user.id: 1") + + @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") + @data(*gen_all_identities()) + def test_registration_form(self, identity): + """ + Tests the registration form showing up with the proper parameters. + + Uses django test client for its session support + """ + client = DjangoTestClient() + # identity k/v pairs will show up in request.META + response = client.get(path='/shib-login/', data={}, follow=False, **identity) + + self.assertEquals(response.status_code, 200) + mail_input_html = '<input class="" id="email" type="email" name="email"' + if not identity.get('mail'): + self.assertContains(response, mail_input_html) + else: + self.assertNotContains(response, mail_input_html) + sn_empty = not identity.get('sn') + given_name_empty = not identity.get('givenName') + displayname_empty = not identity.get('displayName') + fullname_input_html = '<input id="name" type="text" name="name"' + if sn_empty and given_name_empty and displayname_empty: + self.assertContains(response, fullname_input_html) + else: + self.assertNotContains(response, fullname_input_html) + + @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") + @data(*gen_all_identities()) + def test_registration_form_submit(self, identity): + """ + Tests user creation after the registration form that pops is submitted. If there is no shib + ExternalAuthMap in the session, then the created user should take the username and email from the + request. + + Uses django test client for its session support + """ + # First we pop the registration form + self.client.get(path='/shib-login/', data={}, follow=False, **identity) + # Then we have the user answer the registration form + # These are unicode because request.POST returns unicode + postvars = {'email': u'post_email@stanford.edu', + 'username': u'post_username', # django usernames can't be unicode + 'password': u'post_pássword', + 'name': u'post_náme', + 'terms_of_service': u'true', + 'honor_code': u'true'} + + with patch('student.views.AUDIT_LOG') as mock_audit_log: + self.client.post('/create_account', data=postvars) + + mail = identity.get('mail') + + # verify logging of login happening during account creation: + audit_log_calls = mock_audit_log.method_calls + self.assertEquals(len(audit_log_calls), 3) + method_name, args, _kwargs = audit_log_calls[0] + self.assertEquals(method_name, 'info') + self.assertEquals(len(args), 1) + self.assertIn(u'Login success on new account creation', args[0]) + self.assertIn(u'post_username', args[0]) + method_name, args, _kwargs = audit_log_calls[1] + self.assertEquals(method_name, 'info') + self.assertEquals(len(args), 2) + self.assertIn(u'User registered with external_auth', args[0]) + self.assertEquals(u'post_username', args[1]) + method_name, args, _kwargs = audit_log_calls[2] + self.assertEquals(method_name, 'info') + self.assertEquals(len(args), 3) + self.assertIn(u'Updated ExternalAuthMap for ', args[0]) + self.assertEquals(u'post_username', args[1]) + self.assertEquals(u'test_user@stanford.edu', args[2].external_id) + + user = User.objects.get(id=self.client.session['_auth_user_id']) + + # check that the created user has the right email, either taken from shib or user input + if mail: + self.assertEqual(user.email, mail) + self.assertEqual(list(User.objects.filter(email=postvars['email'])), []) + self.assertIsNotNone(User.objects.get(email=mail)) # get enforces only 1 such user + else: + self.assertEqual(user.email, postvars['email']) + self.assertEqual(list(User.objects.filter(email=mail)), []) + self.assertIsNotNone(User.objects.get(email=postvars['email'])) # get enforces only 1 such user + + # check that the created user profile has the right name, either taken from shib or user input + profile = UserProfile.objects.get(user=user) + sn_empty = not identity.get('sn') + given_name_empty = not identity.get('givenName') + displayname_empty = not identity.get('displayName') + + if displayname_empty: + if sn_empty and given_name_empty: + self.assertEqual(profile.name, postvars['name']) + else: + self.assertEqual(profile.name, self.client.session['ExternalAuthMap'].external_name) + self.assertNotIn(u';', profile.name) + else: + self.assertEqual(profile.name, self.client.session['ExternalAuthMap'].external_name) + self.assertEqual(profile.name, identity.get('displayName').decode('utf-8')) + + +@ddt +@override_settings(SESSION_ENGINE='django.contrib.sessions.backends.cache') +class ShibSPTestModifiedCourseware(ModuleStoreTestCase): + """ + Tests for the Shibboleth SP which modify the courseware + """ + + ENABLED_CACHES = ['default', 'mongo_metadata_inheritance', 'loc_cache'] + + request_factory = RequestFactory() + + def setUp(self): + super(ShibSPTestModifiedCourseware, self).setUp() + self.test_user_id = ModuleStoreEnum.UserID.test + + @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") + @data(None, "", "shib:https://idp.stanford.edu/") + def test_course_specific_login_and_reg(self, domain): + """ + Tests that the correct course specific login and registration urls work for shib + """ + course = CourseFactory.create( + org='MITx', + number='999', + display_name='Robot Super Course', + user_id=self.test_user_id, + ) + + # Test for cases where course is found + # set domains + + # temporarily set the branch to draft-preferred so we can update the course + with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course.id): + course.enrollment_domain = domain + self.store.update_item(course, self.test_user_id) + + # setting location to test that GET params get passed through + login_request = self.request_factory.get('/course_specific_login/MITx/999/Robot_Super_Course' + + '?course_id=MITx/999/Robot_Super_Course' + + '&enrollment_action=enroll') + _reg_request = self.request_factory.get('/course_specific_register/MITx/999/Robot_Super_Course' + + '?course_id=MITx/999/course/Robot_Super_Course' + + '&enrollment_action=enroll') + + login_response = course_specific_login(login_request, 'MITx/999/Robot_Super_Course') + reg_response = course_specific_register(login_request, 'MITx/999/Robot_Super_Course') + + if domain and "shib" in domain: + self.assertIsInstance(login_response, HttpResponseRedirect) + self.assertEqual(login_response['Location'], + reverse('shib-login') + + '?course_id=MITx/999/Robot_Super_Course' + + '&enrollment_action=enroll') + self.assertIsInstance(login_response, HttpResponseRedirect) + self.assertEqual(reg_response['Location'], + reverse('shib-login') + + '?course_id=MITx/999/Robot_Super_Course' + + '&enrollment_action=enroll') + else: + self.assertIsInstance(login_response, HttpResponseRedirect) + self.assertEqual(login_response['Location'], + reverse('signin_user') + + '?course_id=MITx/999/Robot_Super_Course' + + '&enrollment_action=enroll') + self.assertIsInstance(login_response, HttpResponseRedirect) + self.assertEqual(reg_response['Location'], + reverse('register_user') + + '?course_id=MITx/999/Robot_Super_Course' + + '&enrollment_action=enroll') + + # Now test for non-existent course + # setting location to test that GET params get passed through + login_request = self.request_factory.get('/course_specific_login/DNE/DNE/DNE' + + '?course_id=DNE/DNE/DNE' + + '&enrollment_action=enroll') + _reg_request = self.request_factory.get('/course_specific_register/DNE/DNE/DNE' + + '?course_id=DNE/DNE/DNE/Robot_Super_Course' + + '&enrollment_action=enroll') + + login_response = course_specific_login(login_request, 'DNE/DNE/DNE') + reg_response = course_specific_register(login_request, 'DNE/DNE/DNE') + + self.assertIsInstance(login_response, HttpResponseRedirect) + self.assertEqual(login_response['Location'], + reverse('signin_user') + + '?course_id=DNE/DNE/DNE' + + '&enrollment_action=enroll') + self.assertIsInstance(login_response, HttpResponseRedirect) + self.assertEqual(reg_response['Location'], + reverse('register_user') + + '?course_id=DNE/DNE/DNE' + + '&enrollment_action=enroll') + + @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") + def test_enrollment_limit_by_domain(self): + """ + Tests that the enrollmentDomain setting is properly limiting enrollment to those who have + the proper external auth + """ + + # create 2 course, one with limited enrollment one without + shib_course = CourseFactory.create( + org='Stanford', + number='123', + display_name='Shib Only', + enrollment_domain='shib:https://idp.stanford.edu/', + user_id=self.test_user_id, + ) + + open_enroll_course = CourseFactory.create( + org='MITx', + number='999', + display_name='Robot Super Course', + enrollment_domain='', + user_id=self.test_user_id, + ) + + # create 3 kinds of students, external_auth matching shib_course, external_auth not matching, no external auth + shib_student = UserFactory.create() + shib_student.save() + extauth = ExternalAuthMap(external_id='testuser@stanford.edu', + external_email='', + external_domain='shib:https://idp.stanford.edu/', + external_credentials="", + user=shib_student) + extauth.save() + + other_ext_student = UserFactory.create() + other_ext_student.username = "teststudent2" + other_ext_student.email = "teststudent2@other.edu" + other_ext_student.save() + extauth = ExternalAuthMap(external_id='testuser1@other.edu', + external_email='', + external_domain='shib:https://other.edu/', + external_credentials="", + user=other_ext_student) + extauth.save() + + int_student = UserFactory.create() + int_student.username = "teststudent3" + int_student.email = "teststudent3@gmail.com" + int_student.save() + + # Tests the two case for courses, limited and not + for course in [shib_course, open_enroll_course]: + for student in [shib_student, other_ext_student, int_student]: + request = self.request_factory.post('/change_enrollment') + + request.POST.update({'enrollment_action': 'enroll', + 'course_id': course.id.to_deprecated_string()}) + request.user = student + response = change_enrollment(request) + # If course is not limited or student has correct shib extauth then enrollment should be allowed + if course is open_enroll_course or student is shib_student: + self.assertEqual(response.status_code, 200) + self.assertTrue(CourseEnrollment.is_enrolled(student, course.id)) + else: + self.assertEqual(response.status_code, 400) + self.assertFalse(CourseEnrollment.is_enrolled(student, course.id)) + + @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") + def test_shib_login_enrollment(self): + """ + A functionality test that a student with an existing shib login + can auto-enroll in a class with GET or POST params. Also tests the direction functionality of + the 'next' GET/POST param + """ + student = UserFactory.create() + extauth = ExternalAuthMap(external_id='testuser@stanford.edu', + external_email='', + external_domain='shib:https://idp.stanford.edu/', + external_credentials="", + internal_password="password", + user=student) + student.set_password("password") + student.save() + extauth.save() + + course = CourseFactory.create( + org='Stanford', + number='123', + display_name='Shib Only', + enrollment_domain='shib:https://idp.stanford.edu/', + user_id=self.test_user_id, + ) + + # use django test client for sessions and url processing + # no enrollment before trying + self.assertFalse(CourseEnrollment.is_enrolled(student, course.id)) + self.client.logout() + params = [ + ('course_id', course.id.to_deprecated_string()), + ('enrollment_action', 'enroll'), + ('next', '/testredirect') + ] + request_kwargs = {'path': '/shib-login/', + 'data': dict(params), + 'follow': False, + 'REMOTE_USER': 'testuser@stanford.edu', + 'Shib-Identity-Provider': 'https://idp.stanford.edu/'} + response = self.client.get(**request_kwargs) + # successful login is a redirect to the URL that handles auto-enrollment + self.assertEqual(response.status_code, 302) + self.assertEqual(response['location'], 'http://testserver/account/finish_auth?{}'.format(urlencode(params))) + + +class ShibUtilFnTest(TestCase): + """ + Tests util functions in shib module + """ + def test__flatten_to_ascii(self): + DIACRITIC = u"àèìòùÀÈÌÒÙáéíóúýÁÉÍÓÚÝâêîôûÂÊÎÔÛãñõÃÑÕäëïöüÿÄËÏÖÜŸåÅçÇ" # pylint: disable=invalid-name + STR_DIACRI = "àèìòùÀÈÌÒÙáéíóúýÁÉÍÓÚÝâêîôûÂÊÎÔÛãñõÃÑÕäëïöüÿÄËÏÖÜŸåÅçÇ" # pylint: disable=invalid-name + FLATTENED = u"aeiouAEIOUaeiouyAEIOUYaeiouAEIOUanoANOaeiouyAEIOUYaAcC" # pylint: disable=invalid-name + self.assertEqual(_flatten_to_ascii('jasön'), 'jason') # umlaut + self.assertEqual(_flatten_to_ascii('Jason包'), 'Jason') # mandarin, so it just gets dropped + self.assertEqual(_flatten_to_ascii('abc'), 'abc') # pass through + + unicode_test = _flatten_to_ascii(DIACRITIC) + self.assertEqual(unicode_test, FLATTENED) + self.assertIsInstance(unicode_test, unicode) + + str_test = _flatten_to_ascii(STR_DIACRI) + self.assertEqual(str_test, FLATTENED) + self.assertIsInstance(str_test, str) diff --git a/openedx/core/djangoapps/external_auth/tests/test_ssl.py b/openedx/core/djangoapps/external_auth/tests/test_ssl.py new file mode 100644 index 0000000..910a20e --- /dev/null +++ b/openedx/core/djangoapps/external_auth/tests/test_ssl.py @@ -0,0 +1,418 @@ +""" +Provides unit tests for SSL based authentication portions +of the external_auth app. +""" +# pylint: disable=no-member +import copy +import unittest + +from contextlib import contextmanager +from django.conf import settings +from django.contrib.auth import SESSION_KEY +from django.contrib.auth.models import AnonymousUser, User +from django.contrib.sessions.middleware import SessionMiddleware +from django.core.urlresolvers import reverse +from django.test.client import Client +from django.test.client import RequestFactory +from django.test.utils import override_settings +from mock import Mock, patch + +from openedx.core.djangoapps.external_auth.models import ExternalAuthMap +import openedx.core.djangoapps.external_auth.views as external_auth_views +from student.models import CourseEnrollment +from student.roles import CourseStaffRole +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +FEATURES_WITH_SSL_AUTH = settings.FEATURES.copy() +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['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['AUTH_USE_CERTIFICATES'] = False +CACHES_ENABLE_GENERAL = copy.deepcopy(settings.CACHES) +CACHES_ENABLE_GENERAL['general']['BACKEND'] = 'django.core.cache.backends.locmem.LocMemCache' + + +@override_settings(FEATURES=FEATURES_WITH_SSL_AUTH) +@override_settings(CACHES=CACHES_ENABLE_GENERAL) +class SSLClientTest(ModuleStoreTestCase): + """ + Tests SSL Authentication code sections of external_auth + """ + + 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 = '/' + + @contextmanager + def _create_ssl_request(self, url): + """Creates a basic request for SSL use.""" + request = self.factory.get(url) + request.META['SSL_CLIENT_S_DN'] = self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL) + request.user = AnonymousUser() + middleware = SessionMiddleware() + middleware.process_request(request) + request.session.save() + + with patch('edxmako.request_context.get_current_request', return_value=request): + yield request + + @contextmanager + 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() + + with patch('edxmako.request_context.get_current_request', return_value=request): + yield 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): + """ + Validate that an SSL login creates an eamap user and + redirects them to the signup page. + """ + with self._create_ssl_request('/') as request: + response = external_auth_views.ssl_login(request) + + # Response should contain template for signup form, eamap should have user, and internal + # auth should not have a user + self.assertIn('<form role="form" id="register-form" method="post"', response.content) + 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))) + + with self.assertRaises(User.DoesNotExist): + User.objects.get(email=self.USER_EMAIL) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms') + def test_ssl_login_with_signup_cms(self): + """ + Validate that an SSL login creates an eamap user and + redirects them to the signup page on CMS. + """ + self.client.get( + reverse('contentstore.views.login_page'), + SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL) + ) + + 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))) + + with self.assertRaises(User.DoesNotExist): + User.objects.get(email=self.USER_EMAIL) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP) + def test_ssl_login_without_signup_lms(self): + """ + Test IMMEDIATE_SIGNUP feature flag and ensure the user account is automatically created + and the user is redirected to slash. + """ + with self._create_ssl_request('/') as request: + external_auth_views.ssl_login(request) + + # Assert our user exists in both eamap and Users, and that we are logged in + 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))) + 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))) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms') + @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP) + def test_ssl_login_without_signup_cms(self): + """ + Test IMMEDIATE_SIGNUP feature flag and ensure the user account is + automatically created on CMS, and that we are redirected + to courses. + """ + + response = self.client.get( + reverse('contentstore.views.login_page'), + SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL) + ) + self.assertEqual(response.status_code, 302) + self.assertIn('/course', response['location']) + + # Assert our user exists in both eamap and Users, and that we are logged in + 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))) + 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))) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP) + def test_default_login_decorator_ssl(self): + """ + Make sure that SSL login happens if it is enabled on protected + views instead of showing the login form. + """ + response = self.client.get(reverse('dashboard'), follows=True) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse('signin_user'), response['location']) + + 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) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP) + def test_registration_page_bypass(self): + """ + This tests to make sure when immediate signup is on that + the user doesn't get presented with the registration page. + """ + response = self.client.get( + reverse('register_user'), 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) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms') + @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP) + def test_cms_registration_page_bypass(self): + """ + This tests to make sure when immediate signup is on that + the user doesn't get presented with the registration page. + """ + response = self.client.get( + reverse('signup'), follow=True, + SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL) + ) + self.assertEqual(response.status_code, 404) + # assert that we are logged in + self.assertIn(SESSION_KEY, self.client.session) + + # Now that we are logged in, make sure we don't see the registration page + response = self.client.get(reverse('signup'), follow=True) + self.assertEqual(response.status_code, 404) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP) + def test_signin_page_bypass(self): + """ + This tests to make sure when ssl authentication is on + that user doesn't get presented with the login page if they + have a certificate. + """ + # Test that they do signin if they don't have a cert + response = self.client.get(reverse('signin_user')) + self.assertEqual(200, response.status_code) + self.assertIn('login-and-registration-container', response.content) + + # And get directly logged in otherwise + response = self.client.get( + reverse('signin_user'), 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) + + @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. + + The internal password use for certificates has been removed + and this should not fail. + """ + # Create account, break internal password, and activate account + + with self._create_ssl_request('/') as request: + external_auth_views.ssl_login(request) + user = User.objects.get(email=self.USER_EMAIL) + user.set_password('not autogenerated') + user.is_active = True + user.save() + + # Make sure we can still login + self.client.get( + reverse('signin_user'), follow=True, + SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL)) + self.assertIn(SESSION_KEY, self.client.session) + + @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) + + with self._create_normal_request(self.MOCK_URL) as request: + 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 + with self._create_normal_request(self.MOCK_URL) as request: + dec_mock(request) + self.assertTrue(self.mock.called) + self.assertEqual(0, len(ExternalAuthMap.objects.all())) + + # Test valid user + self.mock.reset_mock() + with self._create_ssl_request(self.MOCK_URL) as request: + 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() + with self._create_ssl_request(self.MOCK_URL) as request: + 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) + with self._create_ssl_request(self.MOCK_URL) as request: + 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))) + 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) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE) + 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' + ) + + with self._create_ssl_request('/') as request: + external_auth_views.ssl_login(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.assertNotIn(SESSION_KEY, 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' + ) + + with self._create_ssl_request('/') as request: + external_auth_views.ssl_login(request) + user = User.objects.get(email=self.USER_EMAIL) + CourseEnrollment.enroll(user, course.id) + + CourseStaffRole(course.id).add_users(user) + course_private_url = reverse('course_handler', args=(unicode(course.id),)) + self.assertNotIn(SESSION_KEY, 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) diff --git a/openedx/core/djangoapps/external_auth/views.py b/openedx/core/djangoapps/external_auth/views.py new file mode 100644 index 0000000..c38e8b7 --- /dev/null +++ b/openedx/core/djangoapps/external_auth/views.py @@ -0,0 +1,954 @@ +""" +External Auth Views +""" +import functools +import json +import logging +import random +import re +import string +import fnmatch +import unicodedata +import urllib + +from textwrap import dedent +from openedx.core.djangoapps.external_auth.models import ExternalAuthMap +from openedx.core.djangoapps.external_auth.djangostore import DjangoOpenIDStore + +from django.conf import settings +from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from django.core.validators import validate_email +from django.core.exceptions import ValidationError + +if settings.FEATURES.get('AUTH_USE_CAS'): + from django_cas.views import login as django_cas_login + +from student.helpers import get_next_url_for_login_page +from student.models import UserProfile + +from django.http import HttpResponse, HttpResponseRedirect, HttpResponseForbidden +from django.utils.http import urlquote, is_safe_url +from django.shortcuts import redirect +from django.utils.translation import ugettext as _ + +from edxmako.shortcuts import render_to_response, render_to_string +from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie + +import django_openid_auth.views as openid_views +from django_openid_auth import auth as openid_auth +from openid.consumer.consumer import SUCCESS + +from openid.server.server import Server, ProtocolError, UntrustedReturnURL +from openid.server.trustroot import TrustRoot +from openid.extensions import ax, sreg +from ratelimitbackend.exceptions import RateLimitException + +import student.views +from xmodule.modulestore.django import modulestore +from opaque_keys.edx.locations import SlashSeparatedCourseKey + +log = logging.getLogger("edx.external_auth") +AUDIT_LOG = logging.getLogger("audit") + +SHIBBOLETH_DOMAIN_PREFIX = settings.SHIBBOLETH_DOMAIN_PREFIX +OPENID_DOMAIN_PREFIX = settings.OPENID_DOMAIN_PREFIX + +# ----------------------------------------------------------------------------- +# OpenID Common +# ----------------------------------------------------------------------------- + + +@csrf_exempt +def default_render_failure(request, # pylint: disable=unused-argument + message, + status=403, + template_name='extauth_failure.html', + exception=None): + """Render an Openid error page to the user""" + + log.debug("In openid_failure " + message) + + data = render_to_string(template_name, + dict(message=message, exception=exception)) + + return HttpResponse(data, status=status) + + +# ----------------------------------------------------------------------------- +# OpenID Authentication +# ----------------------------------------------------------------------------- + + +def generate_password(length=12, chars=string.letters + string.digits): + """Generate internal password for externally authenticated user""" + choice = random.SystemRandom().choice + return ''.join([choice(chars) for _i in range(length)]) + + +@csrf_exempt +def openid_login_complete(request, + redirect_field_name=REDIRECT_FIELD_NAME, # pylint: disable=unused-argument + render_failure=None): + """Complete the openid login process""" + + render_failure = (render_failure or default_render_failure) + + openid_response = openid_views.parse_openid_response(request) + if not openid_response: + return render_failure(request, + 'This is an OpenID relying party endpoint.') + + if openid_response.status == SUCCESS: + external_id = openid_response.identity_url + oid_backend = openid_auth.OpenIDBackend() + details = oid_backend._extract_user_details(openid_response) # pylint: disable=protected-access + + log.debug('openid success, details=%s', details) + + url = getattr(settings, 'OPENID_SSO_SERVER_URL', None) + external_domain = "{0}{1}".format(OPENID_DOMAIN_PREFIX, url) + fullname = '%s %s' % (details.get('first_name', ''), + details.get('last_name', '')) + + return _external_login_or_signup( + request, + external_id, + external_domain, + details, + details.get('email', ''), + fullname, + retfun=functools.partial(redirect, get_next_url_for_login_page(request)), + ) + + return render_failure(request, 'Openid failure') + + +def _external_login_or_signup(request, + external_id, + external_domain, + credentials, + email, + fullname, + retfun=None): + """Generic external auth login or signup""" + # pylint: disable=too-many-statements + # see if we have a map from this external_id to an edX username + try: + eamap = ExternalAuthMap.objects.get(external_id=external_id, + external_domain=external_domain) + log.debug(u'Found eamap=%s', eamap) + except ExternalAuthMap.DoesNotExist: + # go render form for creating edX user + eamap = ExternalAuthMap(external_id=external_id, + external_domain=external_domain, + external_credentials=json.dumps(credentials)) + eamap.external_email = email + eamap.external_name = fullname + eamap.internal_password = generate_password() + log.debug(u'Created eamap=%s', eamap) + eamap.save() + + log.info(u"External_Auth login_or_signup for %s : %s : %s : %s", external_domain, external_id, email, fullname) + uses_shibboleth = settings.FEATURES.get('AUTH_USE_SHIB') and external_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX) + uses_certs = settings.FEATURES.get('AUTH_USE_CERTIFICATES') + internal_user = eamap.user + if internal_user is None: + if uses_shibboleth: + # If we are using shib, try to link accounts + # For Stanford shib, the email the idp returns is actually under the control of the user. + # Since the id the idps return is not user-editable, and is of the from "username@stanford.edu", + # use the id to link accounts instead. + try: + link_user = User.objects.get(email=eamap.external_id) + if not ExternalAuthMap.objects.filter(user=link_user).exists(): + # if there's no pre-existing linked eamap, we link the user + eamap.user = link_user + eamap.save() + internal_user = link_user + log.info(u'SHIB: Linking existing account for %s', eamap.external_id) + # now pass through to log in + else: + # otherwise, there must have been an error, b/c we've already linked a user with these external + # creds + failure_msg = _( + "You have already created an account using " + "an external login like WebAuth or Shibboleth. " + "Please contact {tech_support_email} for support." + ).format( + tech_support_email=settings.TECH_SUPPORT_EMAIL, + ) + return default_render_failure(request, failure_msg) + except User.DoesNotExist: + log.info(u'SHIB: No user for %s yet, doing signup', eamap.external_email) + return _signup(request, eamap, retfun) + else: + log.info(u'No user for %s yet. doing signup', eamap.external_email) + return _signup(request, eamap, retfun) + + # We trust shib's authentication, so no need to authenticate using the password again + uname = internal_user.username + if uses_shibboleth: + user = internal_user + # Assuming this 'AUTHENTICATION_BACKENDS' is set in settings, which I think is safe + if settings.AUTHENTICATION_BACKENDS: + auth_backend = settings.AUTHENTICATION_BACKENDS[0] + else: + auth_backend = 'ratelimitbackend.backends.RateLimitModelBackend' + user.backend = auth_backend + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.info(u'Linked user.id: {0} logged in via Shibboleth'.format(user.id)) + else: + AUDIT_LOG.info(u'Linked user "{0}" logged in via Shibboleth'.format(user.email)) + elif uses_certs: + # Certificates are trusted, so just link the user and log the action + user = internal_user + user.backend = 'ratelimitbackend.backends.RateLimitModelBackend' + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.info(u'Linked user_id {0} logged in via SSL certificate'.format(user.id)) + else: + AUDIT_LOG.info(u'Linked user "{0}" logged in via SSL certificate'.format(user.email)) + else: + user = authenticate(username=uname, password=eamap.internal_password, request=request) + if user is None: + # we want to log the failure, but don't want to log the password attempted: + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.warning(u'External Auth Login failed') + else: + AUDIT_LOG.warning(u'External Auth Login failed for "{0}"'.format(uname)) + return _signup(request, eamap, retfun) + + if not user.is_active: + if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'): + # if BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH, we trust external auth and activate any users + # that aren't already active + user.is_active = True + user.save() + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.info(u'Activating user {0} due to external auth'.format(user.id)) + else: + AUDIT_LOG.info(u'Activating user "{0}" due to external auth'.format(uname)) + else: + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.warning(u'User {0} is not active after external login'.format(user.id)) + else: + AUDIT_LOG.warning(u'User "{0}" is not active after external login'.format(uname)) + # TODO: improve error page + msg = 'Account not yet activated: please look for link in your email' + return default_render_failure(request, msg) + + login(request, user) + request.session.set_expiry(0) + + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.info(u"Login success - user.id: {0}".format(user.id)) + else: + AUDIT_LOG.info(u"Login success - {0} ({1})".format(user.username, user.email)) + if retfun is None: + return redirect('/') + return retfun() + + +def _flatten_to_ascii(txt): + """ + Flattens possibly unicode txt to ascii (django username limitation) + @param name: + @return: the flattened txt (in the same type as was originally passed in) + """ + if isinstance(txt, str): + txt = txt.decode('utf-8') + return unicodedata.normalize('NFKD', txt).encode('ASCII', 'ignore') + else: + return unicode(unicodedata.normalize('NFKD', txt).encode('ASCII', 'ignore')) + + +@ensure_csrf_cookie +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 + to create an account on the edX system, and fill in the user + registration form. + + 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 + + if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP', ''): + # do signin immediately, by calling create_account, instead of asking + # student to fill in form. MIT students already have information filed. + username = eamap.external_email.split('@', 1)[0] + username = username.replace('.', '_') + post_vars = dict(username=username, + honor_code=u'true', + terms_of_service=u'true') + log.info(u'doing immediate signup for %s, params=%s', username, post_vars) + student.views.create_account(request, post_vars) + # should check return content for successful completion before + if retfun is not None: + return retfun() + else: + return redirect('/') + + # default conjoin name, no spaces, flattened to ascii b/c django can't handle unicode usernames, sadly + # but this only affects username, not fullname + username = re.sub(r'\s', '', _flatten_to_ascii(eamap.external_name), flags=re.UNICODE) + + context = { + 'has_extauth_info': True, + 'show_signup_immediately': True, + 'extauth_domain': eamap.external_domain, + 'extauth_id': eamap.external_id, + 'extauth_email': eamap.external_email, + 'extauth_username': username, + 'extauth_name': eamap.external_name, + 'ask_for_tos': True, + } + + # Some openEdX instances can't have terms of service for shib users, like + # according to Stanford's Office of General Counsel + uses_shibboleth = (settings.FEATURES.get('AUTH_USE_SHIB') and + eamap.external_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX)) + if uses_shibboleth and settings.FEATURES.get('SHIB_DISABLE_TOS'): + context['ask_for_tos'] = False + + # detect if full name is blank and ask for it from user + context['ask_for_fullname'] = eamap.external_name.strip() == '' + + # validate provided mail and if it's not valid ask the user + try: + validate_email(eamap.external_email) + context['ask_for_email'] = False + except ValidationError: + context['ask_for_email'] = True + + log.info(u'EXTAUTH: Doing signup for %s', eamap.external_id) + + return student.views.register_user(request, extra_context=context) + + +# ----------------------------------------------------------------------------- +# MIT SSL +# ----------------------------------------------------------------------------- + + +def _ssl_dn_extract_info(dn_string): + """ + Extract username, email address (may be anyuser@anydomain.com) and + full name from the SSL DN string. Return (user,email,fullname) if + successful, and None otherwise. + """ + search_string = re.search('/emailAddress=(.*)@([^/]+)', dn_string) + if search_string: + user = search_string.group(1) + email = "%s@%s" % (user, search_string.group(2)) + else: + raise ValueError + search_string = re.search('/CN=([^/]+)/', dn_string) + if search_string: + fullname = search_string.group(1) + else: + raise ValueError + return (user, email, fullname) + + +def ssl_get_cert_from_request(request): + """ + Extract user information from certificate, if it exists, returning (user, email, fullname). + Else return None. + """ + certkey = "SSL_CLIENT_S_DN" # specify the request.META field to use + + cert = request.META.get(certkey, '') + if not cert: + cert = request.META.get('HTTP_' + certkey, '') + if not cert: + try: + # try the direct apache2 SSL key + cert = request._req.subprocess_env.get(certkey, '') # pylint: disable=protected-access + except Exception: # pylint: disable=broad-except + return '' + + return cert + + +def ssl_login_shortcut(func): + """ + Python function decorator for login procedures, to allow direct login + 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_CERTIFICATES']: + return func(*args, **kwargs) + request = args[0] + + if request.user and request.user.is_authenticated(): # don't re-authenticate + return func(*args, **kwargs) + + cert = ssl_get_cert_from_request(request) + if not cert: # no certificate information - show normal login window + return func(*args, **kwargs) + + def retfun(): + """Wrap function again for call by _external_login_or_signup""" + return func(*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, + retfun=retfun + ) + return wrapped + + +@csrf_exempt +def ssl_login(request): + """ + This is called by branding.views.index when + FEATURES['AUTH_USE_CERTIFICATES'] = True + + Used for MIT user authentication. This presumes the web server + (nginx) has been configured to require specific client + certificates. + + If the incoming protocol is HTTPS (SSL) then authenticate via + client certificate. The certificate provides user email and + fullname; this populates the ExternalAuthMap. The user is + nevertheless still asked to complete the edX signup. + + Else continues on with student.views.index, and no authentication. + """ + # Just to make sure we're calling this only at MIT: + if not settings.FEATURES['AUTH_USE_CERTIFICATES']: + return HttpResponseForbidden() + + cert = ssl_get_cert_from_request(request) + + if not cert: + # no certificate information - go onward to main index + return student.views.index(request) + + (_user, email, fullname) = _ssl_dn_extract_info(cert) + + redirect_to = get_next_url_for_login_page(request) + retfun = functools.partial(redirect, redirect_to) + return _external_login_or_signup( + request, + external_id=email, + external_domain="ssl:MIT", + credentials=cert, + email=email, + fullname=fullname, + retfun=retfun + ) + + +# ----------------------------------------------------------------------------- +# CAS (Central Authentication Service) +# ----------------------------------------------------------------------------- +def cas_login(request, next_page=None, required=False): + """ + Uses django_cas for authentication. + CAS is a common authentcation method pioneered by Yale. + See http://en.wikipedia.org/wiki/Central_Authentication_Service + + Does normal CAS login then generates user_profile if nonexistent, + and if login was successful. We assume that user details are + maintained by the central service, and thus an empty user profile + is appropriate. + """ + + ret = django_cas_login(request, next_page, required) + + if request.user.is_authenticated(): + user = request.user + UserProfile.objects.get_or_create( + user=user, + defaults={'name': user.username} + ) + + return ret + + +# ----------------------------------------------------------------------------- +# Shibboleth (Stanford and others. Uses *Apache* environment variables) +# ----------------------------------------------------------------------------- +def shib_login(request): + """ + Uses Apache's REMOTE_USER environment variable as the external id. + This in turn typically uses EduPersonPrincipalName + http://www.incommonfederation.org/attributesummary.html#eduPersonPrincipal + but the configuration is in the shibboleth software. + """ + shib_error_msg = dedent(_( + """ + Your university identity server did not return your ID information to us. + Please try logging in again. (You may need to restart your browser.) + """)) + + if not request.META.get('REMOTE_USER'): + log.error(u"SHIB: no REMOTE_USER found in request.META") + return default_render_failure(request, shib_error_msg) + elif not request.META.get('Shib-Identity-Provider'): + log.error(u"SHIB: no Shib-Identity-Provider in request.META") + return default_render_failure(request, shib_error_msg) + else: + # If we get here, the user has authenticated properly + shib = {attr: request.META.get(attr, '').decode('utf-8') + for attr in ['REMOTE_USER', 'givenName', 'sn', 'mail', 'Shib-Identity-Provider', 'displayName']} + + # Clean up first name, last name, and email address + # TODO: Make this less hardcoded re: format, but split will work + # even if ";" is not present, since we are accessing 1st element + shib['sn'] = shib['sn'].split(";")[0].strip().capitalize() + shib['givenName'] = shib['givenName'].split(";")[0].strip().capitalize() + + # TODO: should we be logging creds here, at info level? + log.info(u"SHIB creds returned: %r", shib) + + fullname = shib['displayName'] if shib['displayName'] else u'%s %s' % (shib['givenName'], shib['sn']) + + redirect_to = get_next_url_for_login_page(request) + retfun = functools.partial(_safe_postlogin_redirect, redirect_to, request.get_host()) + + return _external_login_or_signup( + request, + external_id=shib['REMOTE_USER'], + external_domain=SHIBBOLETH_DOMAIN_PREFIX + shib['Shib-Identity-Provider'], + credentials=shib, + email=shib['mail'], + fullname=fullname, + retfun=retfun + ) + + +def _safe_postlogin_redirect(redirect_to, safehost, default_redirect='/'): + """ + If redirect_to param is safe (not off this host), then perform the redirect. + Otherwise just redirect to '/'. + Basically copied from django.contrib.auth.views.login + @param redirect_to: user-supplied redirect url + @param safehost: which host is safe to redirect to + @return: an HttpResponseRedirect + """ + if is_safe_url(url=redirect_to, host=safehost): + return redirect(redirect_to) + return redirect(default_redirect) + + +def course_specific_login(request, course_id): + """ + Dispatcher function for selecting the specific login method + required by the course + """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) + course = modulestore().get_course(course_key) + if not course: + # couldn't find the course, will just return vanilla signin page + return redirect_with_get('signin_user', request.GET) + + # now the dispatching conditionals. Only shib for now + if ( + settings.FEATURES.get('AUTH_USE_SHIB') and + course.enrollment_domain and + course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX) + ): + return redirect_with_get('shib-login', request.GET) + + # Default fallthrough to normal signin page + return redirect_with_get('signin_user', request.GET) + + +def course_specific_register(request, course_id): + """ + Dispatcher function for selecting the specific registration method + required by the course + """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) + course = modulestore().get_course(course_key) + + if not course: + # couldn't find the course, will just return vanilla registration page + return redirect_with_get('register_user', request.GET) + + # now the dispatching conditionals. Only shib for now + if ( + settings.FEATURES.get('AUTH_USE_SHIB') and + course.enrollment_domain and + course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX) + ): + # shib-login takes care of both registration and login flows + return redirect_with_get('shib-login', request.GET) + + # Default fallthrough to normal registration page + return redirect_with_get('register_user', request.GET) + + +def redirect_with_get(view_name, get_querydict, do_reverse=True): + """ + Helper function to carry over get parameters across redirects + 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: + return redirect("%s?%s" % (url, get_querydict.urlencode(safe='/'))) + return redirect(view_name) + + +# ----------------------------------------------------------------------------- +# OpenID Provider +# ----------------------------------------------------------------------------- + + +def get_xrds_url(resource, request): + """ + Return the XRDS url for a resource + """ + host = request.get_host() + + location = host + '/openid/provider/' + resource + '/' + + if request.is_secure(): + return 'https://' + location + else: + return 'http://' + location + + +def add_openid_simple_registration(request, response, data): + """ + Add simple registration fields to the response if requested. + """ + sreg_data = {} + sreg_request = sreg.SRegRequest.fromOpenIDRequest(request) + sreg_fields = sreg_request.allRequestedFields() + + # if consumer requested simple registration fields, add them + if sreg_fields: + for field in sreg_fields: + if field == 'email' and 'email' in data: + sreg_data['email'] = data['email'] + elif field == 'fullname' and 'fullname' in data: + sreg_data['fullname'] = data['fullname'] + elif field == 'nickname' and 'nickname' in data: + sreg_data['nickname'] = data['nickname'] + + # construct sreg response + sreg_response = sreg.SRegResponse.extractResponse(sreg_request, + sreg_data) + sreg_response.toMessage(response.fields) + + +def add_openid_attribute_exchange(request, response, data): + """ + Add attribute exchange fields to the response if requested. + """ + try: + ax_request = ax.FetchRequest.fromOpenIDRequest(request) + except ax.AXError: + # not using OpenID attribute exchange extension + pass + else: + ax_response = ax.FetchResponse() + + # if consumer requested attribute exchange fields, add them + if ax_request and ax_request.requested_attributes: + for type_uri in ax_request.requested_attributes.iterkeys(): + email_schema = 'http://axschema.org/contact/email' + name_schema = 'http://axschema.org/namePerson' + if type_uri == email_schema and 'email' in data: + ax_response.addValue(email_schema, data['email']) + elif type_uri == name_schema and 'fullname' in data: + ax_response.addValue(name_schema, data['fullname']) + + # construct ax response + ax_response.toMessage(response.fields) + + +def provider_respond(server, request, response, data): + """ + Respond to an OpenID request + """ + # get and add extensions + add_openid_simple_registration(request, response, data) + add_openid_attribute_exchange(request, response, data) + + # create http response from OpenID response + webresponse = server.encodeResponse(response) + http_response = HttpResponse(webresponse.body) + http_response.status_code = webresponse.code + + # add OpenID headers to response + for key, val in webresponse.headers.iteritems(): + http_response[key] = val + + return http_response + + +def validate_trust_root(openid_request): + """ + Only allow OpenID requests from valid trust roots + """ + + trusted_roots = getattr(settings, 'OPENID_PROVIDER_TRUSTED_ROOT', None) + + if not trusted_roots: + # not using trusted roots + return True + + # don't allow empty trust roots + if (not hasattr(openid_request, 'trust_root') or + not openid_request.trust_root): + log.error('no trust_root') + return False + + # ensure trust root parses cleanly (one wildcard, of form *.foo.com, etc.) + trust_root = TrustRoot.parse(openid_request.trust_root) + if not trust_root: + log.error('invalid trust_root') + return False + + # don't allow empty return tos + if (not hasattr(openid_request, 'return_to') or + not openid_request.return_to): + log.error('empty return_to') + return False + + # ensure return to is within trust root + if not trust_root.validateURL(openid_request.return_to): + log.error('invalid return_to') + return False + + # check that the root matches the ones we trust + if not any(r for r in trusted_roots if fnmatch.fnmatch(trust_root, r)): + log.error('non-trusted root') + return False + + return True + + +@csrf_exempt +def provider_login(request): + """ + OpenID login endpoint + """ + # pylint: disable=too-many-statements + # make and validate endpoint + endpoint = get_xrds_url('login', request) + if not endpoint: + return default_render_failure(request, "Invalid OpenID request") + + # initialize store and server + store = DjangoOpenIDStore() + server = Server(store, endpoint) + + # first check to see if the request is an OpenID request. + # If so, the client will have specified an 'openid.mode' as part + # of the request. + if request.method == 'GET': + querydict = dict(request.GET.items()) + else: + querydict = dict(request.POST.items()) + error = False + if 'openid.mode' in request.GET or 'openid.mode' in request.POST: + # decode request + try: + openid_request = server.decodeRequest(querydict) + except (UntrustedReturnURL, ProtocolError): + openid_request = None + + if not openid_request: + return default_render_failure(request, "Invalid OpenID request") + + # don't allow invalid and non-trusted trust roots + if not validate_trust_root(openid_request): + return default_render_failure(request, "Invalid OpenID trust root") + + # checkid_immediate not supported, require user interaction + if openid_request.mode == 'checkid_immediate': + return provider_respond(server, openid_request, + openid_request.answer(False), {}) + + # checkid_setup, so display login page + # (by falling through to the provider_login at the + # bottom of this method). + elif openid_request.mode == 'checkid_setup': + if openid_request.idSelect(): + # remember request and original path + request.session['openid_setup'] = { + 'request': openid_request, + 'url': request.get_full_path(), + 'post_params': request.POST, + } + + # user failed login on previous attempt + if 'openid_error' in request.session: + error = True + del request.session['openid_error'] + + # OpenID response + else: + return provider_respond(server, openid_request, + server.handleRequest(openid_request), {}) + + # handle login redirection: these are also sent to this view function, + # but are distinguished by lacking the openid mode. We also know that + # they are posts, because they come from the popup + elif request.method == 'POST' and 'openid_setup' in request.session: + # get OpenID request from session + openid_setup = request.session['openid_setup'] + openid_request = openid_setup['request'] + openid_request_url = openid_setup['url'] + post_params = openid_setup['post_params'] + # We need to preserve the parameters, and the easiest way to do this is + # through the URL + url_post_params = { + param: post_params[param] for param in post_params if param.startswith('openid') + } + + encoded_params = urllib.urlencode(url_post_params) + + if '?' not in openid_request_url: + openid_request_url = openid_request_url + '?' + encoded_params + else: + openid_request_url = openid_request_url + '&' + encoded_params + + del request.session['openid_setup'] + + # don't allow invalid trust roots + if not validate_trust_root(openid_request): + return default_render_failure(request, "Invalid OpenID trust root") + + # check if user with given email exists + # Failure is redirected to this method (by using the original URL), + # which will bring up the login dialog. + email = request.POST.get('email', None) + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + request.session['openid_error'] = True + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.warning(u"OpenID login failed - Unknown user email") + else: + msg = u"OpenID login failed - Unknown user email: {0}".format(email) + AUDIT_LOG.warning(msg) + return HttpResponseRedirect(openid_request_url) + + # attempt to authenticate user (but not actually log them in...) + # Failure is again redirected to the login dialog. + username = user.username + password = request.POST.get('password', None) + try: + user = authenticate(username=username, password=password, request=request) + except RateLimitException: + AUDIT_LOG.warning(u'OpenID - Too many failed login attempts.') + return HttpResponseRedirect(openid_request_url) + + if user is None: + request.session['openid_error'] = True + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.warning(u"OpenID login failed - invalid password") + else: + AUDIT_LOG.warning( + u"OpenID login failed - password for %s is invalid", email) + return HttpResponseRedirect(openid_request_url) + + # authentication succeeded, so fetch user information + # that was requested + if user is not None and user.is_active: + # remove error from session since login succeeded + if 'openid_error' in request.session: + del request.session['openid_error'] + + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.info(u"OpenID login success - user.id: %s", user.id) + else: + AUDIT_LOG.info( + u"OpenID login success - %s (%s)", user.username, user.email) + # redirect user to return_to location + url = endpoint + urlquote(user.username) + response = openid_request.answer(True, None, url) + + # Note too that this is hardcoded, and not really responding to + # the extensions that were registered in the first place. + results = { + 'nickname': user.username, + 'email': user.email, + 'fullname': user.profile.name, + } + + # the request succeeded: + return provider_respond(server, openid_request, response, results) + + # the account is not active, so redirect back to the login page: + request.session['openid_error'] = True + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.warning( + u"Login failed - Account not active for user.id %s", user.id) + else: + AUDIT_LOG.warning( + u"Login failed - Account not active for user %s", username) + return HttpResponseRedirect(openid_request_url) + + # determine consumer domain if applicable + return_to = request.GET.get('openid.return_to') or request.POST.get('openid.return_to') or '' + if return_to: + matches = re.match(r'\w+:\/\/([\w\.-]+)', return_to) + return_to = matches.group(1) + + # display login page + response = render_to_response('provider_login.html', { + 'error': error, + 'return_to': return_to + }) + + # add custom XRDS header necessary for discovery process + response['X-XRDS-Location'] = get_xrds_url('xrds', request) + return response + + +def provider_identity(request): + """ + XRDS for identity discovery + """ + + response = render_to_response('identity.xml', + {'url': get_xrds_url('login', request)}, + content_type='text/xml') + + # custom XRDS header necessary for discovery process + response['X-XRDS-Location'] = get_xrds_url('identity', request) + return response + + +def provider_xrds(request): + """ + XRDS for endpoint discovery + """ + + response = render_to_response('xrds.xml', + {'url': get_xrds_url('login', request)}, + content_type='text/xml') + + # custom XRDS header necessary for discovery process + response['X-XRDS-Location'] = get_xrds_url('xrds', request) + return response -- libgit2 0.26.0