diff --git a/common/djangoapps/external_auth/views.py b/common/djangoapps/external_auth/views.py
index 9e41d31c77..5cf21ca68d 100644
--- a/common/djangoapps/external_auth/views.py
+++ b/common/djangoapps/external_auth/views.py
@@ -4,19 +4,18 @@ import logging
import random
import re
import string
+import fnmatch
from external_auth.models import ExternalAuthMap
from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login
-from django.contrib.auth.models import Group
from django.contrib.auth.models import User
+from student.models import UserProfile
-from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseRedirect
-from django.shortcuts import render_to_response
+from django.utils.http import urlquote
from django.shortcuts import redirect
-from django.template import RequestContext
from mitxmako.shortcuts import render_to_response, render_to_string
try:
from django.views.decorators.csrf import csrf_exempt
@@ -24,100 +23,132 @@ except ImportError:
from django.contrib.csrf.middleware import csrf_exempt
from django_future.csrf import ensure_csrf_cookie
from util.cache import cache_if_anonymous
-
-from django_openid_auth import auth as openid_auth
-from openid.consumer.consumer import (Consumer, SUCCESS, CANCEL, FAILURE)
+
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
+from openid.server.trustroot import TrustRoot
+from openid.store.filestore import FileOpenIDStore
+from openid.extensions import ax, sreg
import student.views as student_views
log = logging.getLogger("mitx.external_auth")
+
+# -----------------------------------------------------------------------------
+# 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."""
- message = "In openid_failure " + message
- log.debug(message)
- data = render_to_string( template_name, dict(message=message, exception=exception))
+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
-def edXauth_generate_password(length=12, chars=string.letters + string.digits):
+# -----------------------------------------------------------------------------
+# OpenID Authentication
+# -----------------------------------------------------------------------------
+
+
+def generate_password(length=12, chars=string.letters + string.digits):
"""Generate internal password for externally authenticated user"""
- return ''.join([random.choice(chars) for i in range(length)])
+ choice = random.SystemRandom().choice
+ return ''.join([choice(chars) for i in range(length)])
+
@csrf_exempt
-def edXauth_openid_login_complete(request, redirect_field_name=REDIRECT_FIELD_NAME, render_failure=None):
+def openid_login_complete(request,
+ redirect_field_name=REDIRECT_FIELD_NAME,
+ render_failure=None):
"""Complete the openid login process"""
- redirect_to = request.REQUEST.get(redirect_field_name, '')
- render_failure = render_failure or \
- getattr(settings, 'OPENID_RENDER_FAILURE', None) or \
- default_render_failure
-
+ 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.')
+ 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()
+ oid_backend = openid_auth.OpenIDBackend()
details = oid_backend._extract_user_details(openid_response)
log.debug('openid success, details=%s' % details)
- return edXauth_external_login_or_signup(request,
- external_id,
- "openid:%s" % settings.OPENID_SSO_SERVER_URL,
- details,
- details.get('email',''),
- '%s %s' % (details.get('first_name',''),details.get('last_name',''))
- )
-
+ url = getattr(settings, 'OPENID_SSO_SERVER_URL', None)
+ external_domain = "openid:%s" % 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)
+
return render_failure(request, 'Openid failure')
-#-----------------------------------------------------------------------------
-# generic external auth login or signup
-def edXauth_external_login_or_signup(request, external_id, external_domain, credentials, email, fullname,
- retfun=None):
+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,
- )
+ eamap = ExternalAuthMap.objects.get(external_id=external_id,
+ external_domain=external_domain)
log.debug('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 = 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 = edXauth_generate_password()
- log.debug('created eamap=%s' % eamap)
+ eamap.internal_password = generate_password()
+ log.debug('Created eamap=%s' % eamap)
eamap.save()
internal_user = eamap.user
if internal_user is None:
- log.debug('ExtAuth: no user for %s yet, doing signup' % eamap.external_email)
- return edXauth_signup(request, eamap)
-
+ log.debug('No user for %s yet, doing signup' % eamap.external_email)
+ return signup(request, eamap)
+
uname = internal_user.username
user = authenticate(username=uname, password=eamap.internal_password)
if user is None:
- log.warning("External Auth Login failed for %s / %s" % (uname,eamap.internal_password))
- return edXauth_signup(request, eamap)
+ log.warning("External Auth Login failed for %s / %s" %
+ (uname, eamap.internal_password))
+ return signup(request, eamap)
if not user.is_active:
- log.warning("External Auth: user %s is not active" % (uname))
+ log.warning("User %s is not active" % (uname))
# TODO: improve error page
- return render_failure(request, 'Account not yet activated: please look for link in your email')
-
+ 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)
student_views.try_change_enrollment(request)
@@ -125,14 +156,11 @@ def edXauth_external_login_or_signup(request, external_id, external_domain, cred
if retfun is None:
return redirect('/')
return retfun()
-
-
-#-----------------------------------------------------------------------------
-# generic external auth signup
+
@ensure_csrf_cookie
@cache_if_anonymous
-def edXauth_signup(request, eamap=None):
+def signup(request, eamap=None):
"""
Present form to complete for signup via external authentication.
Even though the user has external credentials, he/she still needs
@@ -142,32 +170,39 @@ def edXauth_signup(request, eamap=None):
eamap is an ExteralAuthMap object, specifying the external user
for which to complete the signup.
"""
-
+
if eamap is None:
pass
- request.session['ExternalAuthMap'] = eamap # save this for use by student.views.create_account
-
+ # save this for use by student.views.create_account
+ request.session['ExternalAuthMap'] = eamap
+
+ # default conjoin name, no spaces
+ username = eamap.external_name.replace(' ', '')
+
context = {'has_extauth_info': True,
- 'show_signup_immediately' : True,
+ 'show_signup_immediately': True,
'extauth_email': eamap.external_email,
- 'extauth_username' : eamap.external_name.replace(' ',''), # default - conjoin name, no spaces
+ 'extauth_username': username,
'extauth_name': eamap.external_name,
}
-
- log.debug('ExtAuth: doing signup for %s' % eamap.external_email)
+
+ log.debug('Doing signup for %s' % eamap.external_email)
return student_views.index(request, extra_context=context)
-#-----------------------------------------------------------------------------
+
+# -----------------------------------------------------------------------------
# MIT SSL
+# -----------------------------------------------------------------------------
+
def ssl_dn_extract_info(dn):
- '''
- 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.
- '''
+ """
+ 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)
if ss:
user = ss.group(1)
@@ -181,40 +216,333 @@ def ssl_dn_extract_info(dn):
return None
return (user, email, fullname)
+
@csrf_exempt
-def edXauth_ssl_login(request):
+def ssl_login(request):
"""
- This is called by student.views.index when MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True
+ This is called by student.views.index when
+ MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True
- Used for MIT user authentication. This presumes the web server (nginx) has been configured
- to require specific client certificates.
+ 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.
+ 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.
"""
- certkey = "SSL_CLIENT_S_DN" # specify the request.META field to use
-
- cert = request.META.get(certkey,'')
+ 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,'')
+ cert = request.META.get('HTTP_' + certkey, '')
if not cert:
try:
- cert = request._req.subprocess_env.get(certkey,'') # try the direct apache2 SSL key
- except Exception as err:
- pass
+ # try the direct apache2 SSL key
+ cert = request._req.subprocess_env.get(certkey, '')
+ except Exception:
+ cert = None
+
if not cert:
# no certificate information - go onward to main index
return student_views.index(request)
(user, email, fullname) = ssl_dn_extract_info(cert)
-
- return edXauth_external_login_or_signup(request,
- external_id=email,
- external_domain="ssl:MIT",
- credentials=cert,
- email=email,
- fullname=fullname,
- retfun = functools.partial(student_views.index, request))
+
+ retfun = functools.partial(student_views.index, request)
+ return external_login_or_signup(request,
+ external_id=email,
+ external_domain="ssl:MIT",
+ credentials=cert,
+ email=email,
+ fullname=fullname,
+ retfun=retfun)
+
+
+# -----------------------------------------------------------------------------
+# OpenID Provider
+# -----------------------------------------------------------------------------
+
+
+def get_xrds_url(resource, request):
+ """
+ Return the XRDS url for a resource
+ """
+ host = request.META['HTTP_HOST']
+
+ if not host.endswith('edx.org'):
+ return None
+
+ 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']
+
+ # 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 = FileOpenIDStore('/tmp/openid_provider')
+ server = Server(store, endpoint)
+
+ # handle OpenID request
+ querydict = dict(request.REQUEST.items())
+ error = False
+ if 'openid.mode' in request.GET or 'openid.mode' in request.POST:
+ # decode request
+ openid_request = server.decodeRequest(querydict)
+
+ 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
+ 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()
+ }
+
+ # 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
+ if 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']
+ 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
+ email = request.POST.get('email', None)
+ try:
+ user = User.objects.get(email=email)
+ except User.DoesNotExist:
+ request.session['openid_error'] = True
+ msg = "OpenID login failed - Unknown user email: {0}".format(email)
+ log.warning(msg)
+ return HttpResponseRedirect(openid_request_url)
+
+ # attempt to authenticate user
+ username = user.username
+ password = request.POST.get('password', None)
+ user = authenticate(username=username, password=password)
+ if user is None:
+ request.session['openid_error'] = True
+ msg = "OpenID login failed - password for {0} is invalid"
+ msg = msg.format(email)
+ log.warning(msg)
+ return HttpResponseRedirect(openid_request_url)
+
+ # authentication succeeded, so log user in
+ 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']
+
+ # fullname field comes from user profile
+ profile = UserProfile.objects.get(user=user)
+ log.info("OpenID login success - {0} ({1})".format(user.username,
+ user.email))
+
+ # redirect user to return_to location
+ url = endpoint + urlquote(user.username)
+ response = openid_request.answer(True, None, url)
+
+ return provider_respond(server,
+ openid_request,
+ response,
+ {
+ 'fullname': profile.name,
+ 'email': user.email
+ })
+
+ request.session['openid_error'] = True
+ msg = "Login failed - Account not active for user {0}".format(username)
+ log.warning(msg)
+ return HttpResponseRedirect(openid_request_url)
+
+ # determine consumer domain if applicable
+ return_to = ''
+ if 'openid.return_to' in request.REQUEST:
+ return_to = request.REQUEST['openid.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
+ })
+
+ # 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)},
+ mimetype='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)},
+ mimetype='text/xml')
+
+ # custom XRDS header necessary for discovery process
+ response['X-XRDS-Location'] = get_xrds_url('xrds', request)
+ return response
diff --git a/lms/djangoapps/branding/views.py b/lms/djangoapps/branding/views.py
index e32eb92138..9fe912e947 100644
--- a/lms/djangoapps/branding/views.py
+++ b/lms/djangoapps/branding/views.py
@@ -20,8 +20,8 @@ def index(request):
return redirect(reverse('dashboard'))
if settings.MITX_FEATURES.get('AUTH_USE_MIT_CERTIFICATES'):
- from external_auth.views import edXauth_ssl_login
- return edXauth_ssl_login(request)
+ from external_auth.views import ssl_login
+ return ssl_login(request)
university = branding.get_university(request.META.get('HTTP_HOST'))
if university is None:
diff --git a/lms/envs/common.py b/lms/envs/common.py
index ce08bf9666..36a8d54d3c 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -77,7 +77,7 @@ MITX_FEATURES = {
'ACCESS_REQUIRE_STAFF_FOR_COURSE': False,
'AUTH_USE_OPENID': False,
'AUTH_USE_MIT_CERTIFICATES' : False,
-
+ 'AUTH_USE_OPENID_PROVIDER': False,
}
# Used for A/B testing
@@ -120,6 +120,10 @@ node_paths = [COMMON_ROOT / "static/js/vendor",
]
NODE_PATH = ':'.join(node_paths)
+
+############################ OpenID Provider ##################################
+OPENID_PROVIDER_TRUSTED_ROOTS = ['cs50.net', '*.cs50.net']
+
################################## MITXWEB #####################################
# This is where we stick our compiled template files. Most of the app uses Mako
# templates
diff --git a/lms/envs/dev.py b/lms/envs/dev.py
index 5da84f59f0..974b8c9fd6 100644
--- a/lms/envs/dev.py
+++ b/lms/envs/dev.py
@@ -105,6 +105,7 @@ LMS_MIGRATION_ALLOWED_IPS = ['127.0.0.1']
################################ OpenID Auth #################################
MITX_FEATURES['AUTH_USE_OPENID'] = True
+MITX_FEATURES['AUTH_USE_OPENID_PROVIDER'] = True
MITX_FEATURES['BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'] = True
INSTALLED_APPS += ('external_auth',)
@@ -115,6 +116,8 @@ OPENID_UPDATE_DETAILS_FROM_SREG = True
OPENID_SSO_SERVER_URL = 'https://www.google.com/accounts/o8/id' # TODO: accept more endpoints
OPENID_USE_AS_ADMIN_LOGIN = False
+OPENID_PROVIDER_TRUSTED_ROOTS = ['*']
+
################################ MIT Certificates SSL Auth #################################
MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True
diff --git a/lms/envs/test.py b/lms/envs/test.py
index c164889d79..7cab4cb52c 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -123,6 +123,11 @@ CACHES = {
# Dummy secret key for dev
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
+################################## OPENID ######################################
+MITX_FEATURES['AUTH_USE_OPENID'] = True
+MITX_FEATURES['AUTH_USE_OPENID_PROVIDER'] = True
+OPENID_PROVIDER_TRUSTED_ROOTS = ['*']
+
############################ FILE UPLOADS (ASKBOT) #############################
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
MEDIA_ROOT = TEST_ROOT / "uploads"
diff --git a/lms/templates/identity.xml b/lms/templates/identity.xml
new file mode 100644
index 0000000000..a925493c03
--- /dev/null
+++ b/lms/templates/identity.xml
@@ -0,0 +1,10 @@
+
+Log In
+
+