Commit afe35654 by Jason Bau Committed by Joe Blaylock

A single squashed commit for the Shibboleth feature

parent 886c9880
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'ExternalAuthMap'
db.create_table('external_auth_externalauthmap', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('external_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('external_domain', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('external_credentials', self.gf('django.db.models.fields.TextField')(blank=True)),
('external_email', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('external_name', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=255, blank=True)),
('user', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['auth.User'], unique=True, null=True)),
('internal_password', self.gf('django.db.models.fields.CharField')(max_length=31, blank=True)),
('dtcreated', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('dtsignup', self.gf('django.db.models.fields.DateTimeField')(null=True)),
))
db.send_create_signal('external_auth', ['ExternalAuthMap'])
# Adding unique constraint on 'ExternalAuthMap', fields ['external_id', 'external_domain']
db.create_unique('external_auth_externalauthmap', ['external_id', 'external_domain'])
def backwards(self, orm):
# Removing unique constraint on 'ExternalAuthMap', fields ['external_id', 'external_domain']
db.delete_unique('external_auth_externalauthmap', ['external_id', 'external_domain'])
# Deleting model 'ExternalAuthMap'
db.delete_table('external_auth_externalauthmap')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'external_auth.externalauthmap': {
'Meta': {'unique_together': "(('external_id', 'external_domain'),)", 'object_name': 'ExternalAuthMap'},
'dtcreated': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'dtsignup': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'external_credentials': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'external_domain': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'external_email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'external_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'external_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'internal_password': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}),
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True', 'null': 'True'})
}
}
complete_apps = ['external_auth']
\ No newline at end of file
"""
Tests for Shibboleth Authentication
@jbau
"""
import unittest
from django.conf import settings
from django.http import HttpResponseRedirect
from django.test.client import RequestFactory
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.sessions.backends.base import SessionBase
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.django import modulestore
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from external_auth.models import ExternalAuthMap
from external_auth.views import shib_login, course_specific_login, course_specific_register
from student.views import create_account, change_enrollment
from student.models import UserProfile, Registration, CourseEnrollment
from student.tests.factories import UserFactory
#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
IDP = 'https://idp.stanford.edu/'
REMOTE_USER = 'test_user@stanford.edu'
MAILS = [None, '', 'test_user@stanford.edu']
GIVENNAMES = [None, '', 'Jason', 'jason; John; bob'] # At Stanford, the givenNames can be a list delimited by ';'
SNS = [None, '', 'Bau', 'bau; smith'] # At Stanford, the sns can be a list delimited by ';'
def gen_all_identities():
"""A generator for all combinations of identity inputs"""
def _build_identity_dict(mail, given_name, surname):
""" Helper function to return a dict of test identity """
meta_dict = {}
meta_dict.update({'Shib-Identity-Provider': IDP,
'REMOTE_USER': REMOTE_USER})
if mail is not None:
meta_dict.update({'mail': mail})
if given_name is not None:
meta_dict.update({'givenName': given_name})
if surname is not None:
meta_dict.update({'sn': surname})
return meta_dict
for mail in MAILS:
for given_name in GIVENNAMES:
for surname in SNS:
yield _build_identity_dict(mail, given_name, surname)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class ShibSPTest(ModuleStoreTestCase):
"""
Tests for the Shibboleth SP, which communicates via request.META
(Apache environment variables set by mod_shib)
"""
factory = RequestFactory()
def setUp(self):
self.store = modulestore()
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
def test_shib_login(self):
"""
Tests that a user with a shib ExternalAuthMap gets logged in while when
shib-login is called, while a user without such gets the registration form.
"""
student = UserFactory.create()
extauth = ExternalAuthMap(external_id='testuser@stanford.edu',
external_email='',
external_domain='shib:https://idp.stanford.edu/',
external_credentials="",
user=student)
student.save()
extauth.save()
idps = ['https://idp.stanford.edu/', 'https://someother.idp.com/']
remote_users = ['testuser@stanford.edu', 'testuser2@someother_idp.com']
for idp in idps:
for remote_user in remote_users:
request = self.factory.get('/shib-login')
request.session = SessionBase() # empty session
request.META.update({'Shib-Identity-Provider': idp,
'REMOTE_USER': remote_user})
request.user = AnonymousUser()
response = shib_login(request)
if idp == "https://idp.stanford.edu" and remote_user == 'testuser@stanford.edu':
self.assertIsInstance(response, HttpResponseRedirect)
self.assertEqual(request.user, student)
self.assertEqual(response['Location'], '/')
else:
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<title>Register for")
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
def test_registration_form(self):
"""
Tests the registration form showing up with the proper parameters.
Uses django test client for its session support
"""
for identity in gen_all_identities():
self.client.logout()
request_kwargs = {'path': '/shib-login/', 'data': {}, 'follow': False}
request_kwargs.update(identity)
response = self.client.get(**request_kwargs) # identity k/v pairs will show up in request.META
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 = identity.get('sn', '') == ''
given_name_empty = identity.get('givenName', '') == ''
fullname_input_HTML = '<input id="name" type="text" name="name"'
if sn_empty and given_name_empty:
self.assertContains(response, fullname_input_HTML)
else:
self.assertNotContains(response, fullname_input_HTML)
#clean up b/c we don't want existing ExternalAuthMap for the next run
self.client.session['ExternalAuthMap'].delete()
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
def test_registration_formSubmit(self):
"""
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
"""
for identity in gen_all_identities():
#First we pop the registration form
self.client.logout()
request1_kwargs = {'path': '/shib-login/', 'data': {}, 'follow': False}
request1_kwargs.update(identity)
response1 = self.client.get(**request1_kwargs)
#Then we have the user answer the registration form
postvars = {'email': 'post_email@stanford.edu',
'username': 'post_username',
'password': 'post_password',
'name': 'post_name',
'terms_of_service': 'true',
'honor_code': 'true'}
#use RequestFactory instead of TestClient here because we want access to request.user
request2 = self.factory.post('/create_account', data=postvars)
request2.session = self.client.session
request2.user = AnonymousUser()
response2 = create_account(request2)
user = request2.user
mail = identity.get('mail')
#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 = identity.get('sn', '') == ''
given_name_empty = identity.get('givenName', '') == ''
if sn_empty and given_name_empty:
self.assertEqual(profile.name, postvars['name'])
else:
self.assertEqual(profile.name, request2.session['ExternalAuthMap'].external_name)
#clean up for next loop
request2.session['ExternalAuthMap'].delete()
UserProfile.objects.filter(user=user).delete()
Registration.objects.filter(user=user).delete()
user.delete()
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
def test_course_specificLoginAndReg(self):
"""
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')
# Test for cases where course is found
for domain in ["", "shib:https://idp.stanford.edu/"]:
#set domains
course.enrollment_domain = domain
metadata = own_metadata(course)
metadata['enrollment_domain'] = domain
self.store.update_metadata(course.location.url(), metadata)
#setting location to test that GET params get passed through
login_request = self.factory.get('/course_specific_login/MITx/999/Robot_Super_Course' +
'?course_id=MITx/999/Robot_Super_Course' +
'&enrollment_action=enroll')
reg_request = self.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 "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.factory.get('/course_specific_login/DNE/DNE/DNE' +
'?course_id=DNE/DNE/DNE' +
'&enrollment_action=enroll')
reg_request = self.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.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
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
course1 = CourseFactory.create(org='Stanford', number='123', display_name='Shib Only')
course1.enrollment_domain = 'shib:https://idp.stanford.edu/'
metadata = own_metadata(course1)
metadata['enrollment_domain'] = course1.enrollment_domain
self.store.update_metadata(course1.location.url(), metadata)
course2 = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course')
course2.enrollment_domain = ''
metadata = own_metadata(course2)
metadata['enrollment_domain'] = course2.enrollment_domain
self.store.update_metadata(course2.location.url(), metadata)
# create 3 kinds of students, external_auth matching course1, external_auth not matching, no external auth
student1 = UserFactory.create()
student1.save()
extauth = ExternalAuthMap(external_id='testuser@stanford.edu',
external_email='',
external_domain='shib:https://idp.stanford.edu/',
external_credentials="",
user=student1)
extauth.save()
student2 = UserFactory.create()
student2.username = "teststudent2"
student2.email = "teststudent2@other.edu"
student2.save()
extauth = ExternalAuthMap(external_id='testuser1@other.edu',
external_email='',
external_domain='shib:https://other.edu/',
external_credentials="",
user=student2)
extauth.save()
student3 = UserFactory.create()
student3.username = "teststudent3"
student3.email = "teststudent3@gmail.com"
student3.save()
#Tests the two case for courses, limited and not
for course in [course1, course2]:
for student in [student1, student2, student3]:
request = self.factory.post('/change_enrollment')
request.POST.update({'enrollment_action': 'enroll',
'course_id': course.id})
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 course2 or student is student1:
self.assertEqual(response.status_code, 200)
self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 1)
#clean up
CourseEnrollment.objects.filter(user=student, course_id=course.id).delete()
else:
self.assertEqual(response.status_code, 400)
self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 0)
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
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 params
"""
if not settings.MITX_FEATURES.get('AUTH_USE_SHIB'):
return
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')
course.enrollment_domain = 'shib:https://idp.stanford.edu/'
metadata = own_metadata(course)
metadata['enrollment_domain'] = course.enrollment_domain
self.store.update_metadata(course.location.url(), metadata)
#use django test client for sessions and url processing
#no enrollment before trying
self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 0)
self.client.logout()
request_kwargs = {'path': '/shib-login/',
'data': {'enrollment_action': 'enroll', 'course_id': course.id},
'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 "/"
self.assertEqual(response.status_code, 302)
self.assertEqual(response['location'], 'http://testserver/')
#now there is enrollment
self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 1)
......@@ -6,17 +6,24 @@ import re
import string
import fnmatch
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
from student.models import UserProfile, TestCenterUser, TestCenterRegistration
from django.http import HttpResponse, HttpResponseRedirect
from django.http import HttpResponse, HttpResponseRedirect, HttpRequest
from django.utils.http import urlquote
from django.shortcuts import redirect
from django.utils.translation import ugettext as _
from mitxmako.shortcuts import render_to_response, render_to_string
try:
from django.views.decorators.csrf import csrf_exempt
......@@ -40,6 +47,7 @@ from courseware.model_data import ModelDataCache
from xmodule.modulestore.django import modulestore
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError
log = logging.getLogger("mitx.external_auth")
......@@ -157,7 +165,15 @@ def external_login_or_signup(request,
login(request, user)
request.session.set_expiry(0)
student_views.try_change_enrollment(request)
# Now to try enrollment
# Need to special case Shibboleth here because it logs in via a GET.
# testing request.method for extra paranoia
if 'shib:' in external_domain and request.method == 'GET':
enroll_request = make_shib_enrollment_request(request)
student_views.try_change_enrollment(enroll_request)
else:
student_views.try_change_enrollment(request)
log.info("Login success - {0} ({1})".format(user.username, user.email))
if retfun is None:
return redirect('/')
......@@ -188,14 +204,25 @@ def signup(request, eamap=None):
context = {'has_extauth_info': True,
'show_signup_immediately': True,
'extauth_id': eamap.external_id,
'extauth_email': eamap.external_email,
'extauth_username': username,
'extauth_name': eamap.external_name,
}
# 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.debug('Doing signup for %s' % eamap.external_email)
return student_views.index(request, extra_context=context)
return student_views.register_user(request, extra_context=context)
# -----------------------------------------------------------------------------
......@@ -305,6 +332,125 @@ def ssl_login(request):
# -----------------------------------------------------------------------------
# Shibboleth (Stanford and others. Uses *Apache* environment variables)
# -----------------------------------------------------------------------------
def shib_login(request, retfun=None):
"""
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'):
return default_render_failure(request, shib_error_msg)
else:
#if we get here, the user has authenticated properly
attrs = ['REMOTE_USER', 'givenName', 'sn', 'mail',
'Shib-Identity-Provider']
shib = {}
for attr in attrs:
shib[attr] = request.META.get(attr, '')
#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()
return external_login_or_signup(request,
external_id=shib['REMOTE_USER'],
external_domain="shib:" + shib['Shib-Identity-Provider'],
credentials=shib,
email=shib['mail'],
fullname="%s %s" % (shib['givenName'], shib['sn']),
retfun=retfun)
def make_shib_enrollment_request(request):
"""
Need this hack function because shibboleth logins don't happen over POST
but change_enrollment expects its request to be a POST, with
enrollment_action and course_id POST parameters.
"""
enroll_request = HttpRequest()
enroll_request.user = request.user
enroll_request.session = request.session
enroll_request.method = "POST"
# copy() also makes GET and POST mutable
# See https://docs.djangoproject.com/en/dev/ref/request-response/#django.http.QueryDict.update
enroll_request.GET = request.GET.copy()
enroll_request.POST = request.POST.copy()
# also have to copy these GET parameters over to POST
if "enrollment_action" not in enroll_request.POST and "enrollment_action" in enroll_request.GET:
enroll_request.POST.setdefault('enrollment_action', enroll_request.GET.get('enrollment_action'))
if "course_id" not in enroll_request.POST and "course_id" in enroll_request.GET:
enroll_request.POST.setdefault('course_id', enroll_request.GET.get('course_id'))
return enroll_request
def course_specific_login(request, course_id):
"""
Dispatcher function for selecting the specific login method
required by the course
"""
query_string = request.META.get("QUERY_STRING", '')
try:
course = course_from_id(course_id)
except ItemNotFoundError:
#couldn't find the course, will just return vanilla signin page
return redirect_with_querystring('signin_user', query_string)
#now the dispatching conditionals. Only shib for now
if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and 'shib:' in course.enrollment_domain:
return redirect_with_querystring('shib-login', query_string)
#Default fallthrough to normal signin page
return redirect_with_querystring('signin_user', query_string)
def course_specific_register(request, course_id):
"""
Dispatcher function for selecting the specific registration method
required by the course
"""
query_string = request.META.get("QUERY_STRING", '')
try:
course = course_from_id(course_id)
except ItemNotFoundError:
#couldn't find the course, will just return vanilla registration page
return redirect_with_querystring('register_user', query_string)
#now the dispatching conditionals. Only shib for now
if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and 'shib:' in course.enrollment_domain:
#shib-login takes care of both registration and login flows
return redirect_with_querystring('shib-login', query_string)
#Default fallthrough to normal registration page
return redirect_with_querystring('register_user', query_string)
def redirect_with_querystring(view_name, query_string):
"""
Helper function to add query string to redirect views
"""
if query_string:
return redirect("%s?%s" % (reverse(view_name), query_string))
return redirect(view_name)
# -----------------------------------------------------------------------------
# OpenID Provider
# -----------------------------------------------------------------------------
......
......@@ -230,7 +230,7 @@ def signin_user(request):
@ensure_csrf_cookie
def register_user(request):
def register_user(request, extra_context={}):
"""
This view will display the non-modal registration form
"""
......@@ -241,6 +241,8 @@ def register_user(request):
'course_id': request.GET.get('course_id'),
'enrollment_action': request.GET.get('enrollment_action')
}
context.update(extra_context)
return render_to_response('register.html', context)
......@@ -282,9 +284,19 @@ def dashboard(request):
# Get the 3 most recent news
top_news = _get_news(top=3) if not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False) else None
# get info w.r.t ExternalAuthMap
external_auth_map = None
if 'external_auth' in settings.INSTALLED_APPS:
from external_auth.models import ExternalAuthMap
try:
external_auth_map = ExternalAuthMap.objects.get(user=user)
except ExternalAuthMap.DoesNotExist:
pass
context = {'courses': courses,
'message': message,
'external_auth_map': external_auth_map,
'staff_access': staff_access,
'errored_courses': errored_courses,
'show_courseware_links_for': show_courseware_links_for,
......@@ -571,11 +583,19 @@ def create_account(request, post_override=None):
# if doing signup for an external authorization, then get email, password, name from the eamap
# don't use the ones from the form, since the user could have hacked those
# unless originally we didn't get a valid email or name from the external auth
DoExternalAuth = 'ExternalAuthMap' in request.session
if DoExternalAuth:
eamap = request.session['ExternalAuthMap']
email = eamap.external_email
name = eamap.external_name
try:
validate_email(eamap.external_email)
email = eamap.external_email
except ValidationError:
email = post_vars.get('email', '')
if eamap.external_name.strip() == '':
name = post_vars.get('name', '')
else:
name = eamap.external_name
password = eamap.internal_password
post_vars = dict(post_vars.items())
post_vars.update(dict(email=email, name=name, password=password))
......@@ -665,8 +685,6 @@ def create_account(request, post_override=None):
login(request, login_user)
request.session.set_expiry(0)
try_change_enrollment(request)
if DoExternalAuth:
eamap.user = login_user
eamap.dtsignup = datetime.datetime.now(UTC)
......@@ -678,6 +696,8 @@ def create_account(request, post_override=None):
login_user.is_active = True
login_user.save()
try_change_enrollment(request)
statsd.increment("common.student.account_created")
js = {'success': True}
......
......@@ -181,6 +181,8 @@ class CourseFields(object):
checklists = List(scope=Scope.settings)
info_sidebar_name = String(scope=Scope.settings, default='Course Handouts')
show_timezone = Boolean(help="True if timezones should be shown on dates in the courseware", scope=Scope.settings, default=True)
enrollment_domain = String(help="External login method associated with user accounts allowed to register in course",
scope=Scope.settings)
# An extra property is used rather than the wiki_slug/number because
# there are courses that change the number for different runs. This allows
......
......@@ -15,6 +15,7 @@ from xmodule.modulestore import Location
from xmodule.x_module import XModule, XModuleDescriptor
from student.models import CourseEnrollmentAllowed
from external_auth.models import ExternalAuthMap
from courseware.masquerade import is_masquerading_as_student
from django.utils.timezone import UTC
......@@ -130,15 +131,33 @@ def _has_access_course_desc(user, course, action):
def can_enroll():
"""
If the course has an enrollment period, check whether we are in it.
First check if restriction of enrollment by login method is enabled, both
globally and by the course.
If it is, then the user must pass the criterion set by the course, e.g. that ExternalAuthMap
was set by 'shib:https://idp.stanford.edu/", in addition to requirements below.
Rest of requirements:
Enrollment can only happen in the course enrollment period, if one exists.
or
(CourseEnrollmentAllowed always overrides)
(staff can always enroll)
"""
# if using registration method to restrict (say shibboleth)
if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain:
if user is not None and user.is_authenticated() and \
ExternalAuthMap.objects.filter(user=user, external_domain=course.enrollment_domain):
debug("Allow: external_auth of " + course.enrollment_domain)
reg_method_ok = True
else:
reg_method_ok = False
else:
reg_method_ok = True #if not using this access check, it's always OK.
now = datetime.now(UTC())
start = course.enrollment_start
end = course.enrollment_end
if (start is None or now > start) and (end is None or now < end):
if reg_method_ok and (start is None or now > start) and (end is None or now < end):
# in enrollment period, so any user is allowed to enroll.
debug("Allow: in enrollment period")
return True
......
......@@ -81,7 +81,7 @@ class AccessTestCase(TestCase):
u = Mock()
yesterday = datetime.datetime.now(UTC()) - datetime.timedelta(days=1)
tomorrow = datetime.datetime.now(UTC()) + datetime.timedelta(days=1)
c = Mock(enrollment_start=yesterday, enrollment_end=tomorrow)
c = Mock(enrollment_start=yesterday, enrollment_end=tomorrow, enrollment_domain='')
# User can enroll if it is between the start and end dates
self.assertTrue(access._has_access_course_desc(u, c, 'enroll'))
......@@ -91,7 +91,7 @@ class AccessTestCase(TestCase):
u = Mock(email='test@edx.org', is_staff=False)
u.is_authenticated.return_value = True
c = Mock(enrollment_start=tomorrow, enrollment_end=tomorrow, id='edX/test/2012_Fall')
c = Mock(enrollment_start=tomorrow, enrollment_end=tomorrow, id='edX/test/2012_Fall', enrollment_domain='')
allowed = CourseEnrollmentAllowedFactory(email=u.email, course_id=c.id)
......@@ -101,7 +101,7 @@ class AccessTestCase(TestCase):
u = Mock(email='test@edx.org', is_staff=True)
u.is_authenticated.return_value = True
c = Mock(enrollment_start=tomorrow, enrollment_end=tomorrow, id='edX/test/Whenever')
c = Mock(enrollment_start=tomorrow, enrollment_end=tomorrow, id='edX/test/Whenever', enrollment_domain='')
self.assertTrue(access._has_access_course_desc(u, c, 'enroll'))
# TODO:
......
......@@ -138,6 +138,10 @@ MKTG_URL_LINK_MAP.update(ENV_TOKENS.get('MKTG_URL_LINK_MAP', {}))
#Timezone overrides
TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
#Additional installed apps
for app in ENV_TOKENS.get('ADDL_INSTALLED_APPS', []):
INSTALLED_APPS += (app,)
for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items():
MITX_FEATURES[feature] = value
......
......@@ -91,6 +91,10 @@ MITX_FEATURES = {
'AUTH_USE_OPENID': False,
'AUTH_USE_MIT_CERTIFICATES': False,
'AUTH_USE_OPENID_PROVIDER': False,
'AUTH_USE_SHIB': False,
# Enables ability to restrict enrollment in specific courses by the user account login method
'RESTRICT_ENROLL_BY_REG_METHOD': False,
# analytics experiments
'ENABLE_INSTRUCTOR_ANALYTICS': False,
......
......@@ -232,6 +232,9 @@ FILE_UPLOAD_HANDLERS = (
'django.core.files.uploadhandler.TemporaryFileUploadHandler',
)
MITX_FEATURES['AUTH_USE_SHIB'] = True
MITX_FEATURES['RESTRICT_ENROLL_BY_REG_METHOD'] = True
########################### PIPELINE #################################
PIPELINE_SASS_ARGUMENTS = '--debug-info --require {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT)
......
......@@ -135,6 +135,10 @@ SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
MITX_FEATURES['AUTH_USE_OPENID'] = True
MITX_FEATURES['AUTH_USE_OPENID_PROVIDER'] = True
################################## SHIB #######################################
MITX_FEATURES['AUTH_USE_SHIB'] = True
MITX_FEATURES['RESTRICT_ENROLL_BY_REG_METHOD'] = True
OPENID_CREATE_USERS = False
OPENID_UPDATE_DETAILS_FROM_SREG = True
OPENID_USE_AS_ADMIN_LOGIN = False
......
......@@ -24,6 +24,26 @@
event.preventDefault();
});
## making the conditional around this entire JS block for sanity
%if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain:
$('#class_enroll_form').on('ajax:complete', function(event, xhr) {
if(xhr.status == 200) {
location.href = "${reverse('dashboard')}";
} else if (xhr.status == 403) {
location.href = "${reverse('course-specific-register', args=[course.id])}?course_id=${course.id}&enrollment_action=enroll";
} else if (xhr.status == 400) { //This means the user did not have permission
$('#register_error').html('This course has restricted enrollment. Sorry, you do not have permission to enroll.<br />' +
'You may need to log out and re-login with a university account, such as WebAuth'
).css("display", "block");
} else {
$('#register_error').html(
(xhr.responseText ? xhr.responseText : 'An error occurred. Please try again later.')
).css("display", "block");
}
});
%else:
$('#class_enroll_form').on('ajax:complete', function(event, xhr) {
if(xhr.status == 200) {
location.href = "${reverse('dashboard')}";
......@@ -35,13 +55,16 @@
).css("display", "block");
}
});
%endif
})(this)
</script>
<script src="${static.url('js/course_info.js')}"></script>
</%block>
<%block name="title"><title>About ${course.number}</title></%block>
<section class="course-info">
......@@ -92,7 +115,7 @@
</div>
</div>
</header>
<section class="container">
<section class="details">
<nav>
......
......@@ -138,8 +138,14 @@
<span class="title"><div class="icon name-icon"></div>Full Name (<a href="#apply_name_change" rel="leanModal" class="edit-name">edit</a>)</span> <span class="data">${ user.profile.name | h }</span>
</li>
<li>
<span class="title"><div class="icon email-icon"></div>Email (<a href="#change_email" rel="leanModal" class="edit-email">edit</a>)</span> <span class="data">${ user.email | h }</span>
<span class="title"><div class="icon email-icon"></div>Email
% if external_auth_map is None or 'shib' not in external_auth_map.external_domain:
(<a href="#change_email" rel="leanModal" class="edit-email">edit</a>)
% endif
</span> <span class="data">${ user.email | h }</span>
</li>
% if external_auth_map is None or 'shib' not in external_auth_map.external_domain:
<li>
<span class="title"><a href="#password_reset_complete" rel="leanModal" id="pwd_reset_button">Reset Password</a></span>
<form id="password_reset_form" method="post" data-remote="true" action="${reverse('password_reset')}">
......@@ -147,6 +153,8 @@
<!-- <input type="submit" id="pwd_reset_button" value="Reset Password" /> -->
</form>
</li>
% endif
</ul>
</section>
......
......@@ -2,10 +2,10 @@
"http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<title>OpenID failed</title>
<title>External Authentication failed</title>
</head>
<body>
<h1>OpenID failed</h1>
<h1>External Authentication failed</h1>
<p>${message}</p>
</body>
</html>
......@@ -95,16 +95,26 @@ site_status_msg = get_site_status_msg(course_id)
% endif
</%block>
% if not settings.MITX_FEATURES['DISABLE_LOGIN_BUTTON']:
<li class="nav-global-04">
<a class="cta cta-register" href="/register">Register Now</a>
</li>
% if course and settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain:
<li class="nav-global-04">
<a class="cta cta-register" href="${reverse('course-specific-register', args=[course.id])}">Register Now</a>
</li>
% else:
<li class="nav-global-04">
<a class="cta cta-register" href="/register">Register Now</a>
</li>
% endif
% endif
</ol>
<ol class="right nav-courseware">
<li class="nav-courseware-01">
% if not settings.MITX_FEATURES['DISABLE_LOGIN_BUTTON']:
<a class="cta cta-login" href="/login${login_query()}">Log in</a>
% if course and settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain:
<a class="cta cta-login" href="${reverse('course-specific-login', args=[course.id])}${login_query()}">Log in</a>
% else:
<a class="cta cta-login" href="/login${login_query()}">Log in</a>
% endif
% endif
</li>
</ol>
......
......@@ -136,16 +136,37 @@
% else:
<div class="message">
<h3 class="message-title">Welcome ${extauth_email}</h3>
<h3 class="message-title">Welcome ${extauth_id}</h3>
<p class="message-copy">Enter a public username:</p>
</div>
<ol class="list-input">
% if ask_for_email:
<li class="field required text" id="field-email">
<label for="email">E-mail</label>
<input class="" id="email" type="email" name="email" value="" placeholder="example: username@domain.com" />
</li>
% endif
<li class="field required text" id="field-username">
<label for="username">Public Username</label>
<input id="username" type="text" name="username" value="${extauth_username}" placeholder="example: JaneDoe" required aria-required="true" />
<span class="tip tip-input">Will be shown in any discussions or forums you participate in</span>
</li>
% if ask_for_fullname:
<li class="field required text" id="field-name">
<label for="name">Full Name</label>
<input id="name" type="text" name="name" value="" placeholder="example: Jane Doe" />
<span class="tip tip-input">Needed for any certificates you may earn <strong>(cannot be changed later)</strong></span>
</li>
% endif
</ol>
% endif
......@@ -246,6 +267,8 @@
<h3 class="sr">Registration Help</h3>
</header>
% if has_extauth_info is UNDEFINED:
<div class="cta">
<h3>Already registered?</h3>
<p class="instructions">
......@@ -254,6 +277,8 @@
</a>
</p>
</div>
% endif
## TODO: Use a %block tag or something to allow themes to
## override in a more generalizable fashion.
......
......@@ -32,11 +32,23 @@
<label data-field="name" for="signup_fullname">Full Name *</label>
<input id="signup_fullname" type="text" name="name" placeholder="e.g. Your Name (for certificates)" required />
% else:
<p><i>Welcome</i> ${extauth_email}</p><br/>
<p><i>Welcome</i> ${extauth_id}</p><br/>
<p><i>Enter a public username:</i></p>
<label data-field="username" for="signup_username">Public Username *</label>
<input id="signup_username" type="text" name="username" value="${extauth_username}" placeholder="e.g. yourname (shown on forums)" required />
<input id="signup_username" type="text" name="username" value="${extauth_username}" placeholder="e.g. yourname (shown on forums)" required />
% if ask_for_email:
<label data-field="email" for="signup_email">E-mail *</label>
<input id="signup_email" type="email" name="email" placeholder="e.g. yourname@domain.com" required />
% endif
% if ask_for_fullname:
<label data-field="name" for="signup_fullname">Full Name *</label>
<input id="signup_fullname" type="text" name="name" placeholder="e.g. Your Name (for certificates)" required />
% endif
% endif
</div>
......
......@@ -361,6 +361,21 @@ if settings.MITX_FEATURES.get('AUTH_USE_OPENID'):
url(r'^openid/logo.gif$', 'django_openid_auth.views.logo', name='openid-logo'),
)
if settings.MITX_FEATURES.get('AUTH_USE_SHIB'):
urlpatterns += (
url(r'^shib-login/$', 'external_auth.views.shib_login', name='shib-login'),
)
if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD'):
urlpatterns += (
url(r'^course_specific_login/(?P<course_id>[^/]+/[^/]+/[^/]+)/$',
'external_auth.views.course_specific_login', name='course-specific-login'),
url(r'^course_specific_register/(?P<course_id>[^/]+/[^/]+/[^/]+)/$',
'external_auth.views.course_specific_register', name='course-specific-register'),
)
if settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'):
urlpatterns += (
url(r'^openid/provider/login/$', 'external_auth.views.provider_login', name='openid-provider-login'),
......
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "lms.envs.aws")
os.environ.setdefault("SERVICE_VARIANT", "lms")
# This application object is used by the development server
# as well as any WSGI server configured to use this file.
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()
from django.conf import settings
from xmodule.modulestore.django import modulestore
for store_name in settings.MODULESTORE:
modulestore(store_name)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment