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 @@ + + + + + http://specs.openid.net/auth/2.0/signon + http://openid.net/signon/1.1 + ${url} + + + diff --git a/lms/templates/provider_login.html b/lms/templates/provider_login.html new file mode 100644 index 0000000000..620e0c4191 --- /dev/null +++ b/lms/templates/provider_login.html @@ -0,0 +1,52 @@ +<%inherit file="main.html" /> +<%namespace name='static' file='static_content.html'/> + +<%block name="headextra"> + + + + +
diff --git a/lms/templates/xrds.xml b/lms/templates/xrds.xml new file mode 100644 index 0000000000..2f7713bc8a --- /dev/null +++ b/lms/templates/xrds.xml @@ -0,0 +1,11 @@ + + + + + http://specs.openid.net/auth/2.0/server + http://openid.net/sreg/1.0 + http://openid.net/srv/ax/1.0 + ${url} + + + diff --git a/lms/urls.py b/lms/urls.py index 86d654eb40..d88aef17f6 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -215,9 +215,17 @@ if settings.DEBUG: if settings.MITX_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.edXauth_openid_login_complete', name='openid-complete'), + url(r'^openid/complete/$', 'external_auth.views.openid_login_complete', name='openid-complete'), url(r'^openid/logo.gif$', 'django_openid_auth.views.logo', name='openid-logo'), - ) + ) + +if settings.MITX_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/(?:[\w%\. ]+)$', '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') + ) if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'): urlpatterns += (