Add audit log definition, and use for logging of logins in external_auth and student apps. Move test_login to student app. Improve conditional tests for Shibboleth login logic. (Does not include reconfiguring log settings.)

This commit is contained in:
Brian Wilson
2013-07-23 18:13:43 -04:00
parent 50ef0d1c09
commit 635d36fcf9
8 changed files with 397 additions and 260 deletions

View File

@@ -17,9 +17,9 @@ 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 student.models import TestCenterUser, TestCenterRegistration
from django.http import HttpResponse, HttpResponseRedirect, HttpRequest
from django.http import HttpResponse, HttpResponseRedirect, HttpRequest, HttpResponseForbidden
from django.utils.http import urlquote
from django.shortcuts import redirect
from django.utils.translation import ugettext as _
@@ -50,7 +50,10 @@ from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError
log = logging.getLogger("mitx.external_auth")
AUDIT_LOG = logging.getLogger("audit")
SHIBBOLETH_DOMAIN_PREFIX = 'shib:'
OPENID_DOMAIN_PREFIX = 'openid:'
# -----------------------------------------------------------------------------
# OpenID Common
@@ -81,7 +84,7 @@ def default_render_failure(request,
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)])
return ''.join([choice(chars) for _i in range(length)])
@csrf_exempt
@@ -105,27 +108,29 @@ def openid_login_complete(request,
log.debug('openid success, details=%s', details)
url = getattr(settings, 'OPENID_SSO_SERVER_URL', None)
external_domain = "openid:%s" % url
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)
return _external_login_or_signup(
request,
external_id,
external_domain,
details,
details.get('email', ''),
fullname
)
return render_failure(request, 'Openid failure')
def 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
@@ -142,13 +147,13 @@ def external_login_or_signup(request,
eamap.external_name = fullname
eamap.internal_password = generate_password()
log.debug('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.MITX_FEATURES.get('AUTH_USE_SHIB') and external_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX)
internal_user = eamap.user
if internal_user is None:
if settings.MITX_FEATURES.get('AUTH_USE_SHIB'):
if uses_shibboleth:
# if we are using shib, try to link accounts using email
try:
link_user = User.objects.get(email=eamap.external_email)
@@ -169,14 +174,14 @@ def external_login_or_signup(request,
return default_render_failure(request, failure_msg)
except User.DoesNotExist:
log.info('SHIB: No user for %s yet, doing signup', eamap.external_email)
return signup(request, eamap)
return _signup(request, eamap)
else:
log.info('No user for %s yet. doing signup', eamap.external_email)
return signup(request, eamap)
return _signup(request, eamap)
# We trust shib's authentication, so no need to authenticate using the password again
if settings.MITX_FEATURES.get('AUTH_USE_SHIB'):
uname = internal_user.username
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:
@@ -184,32 +189,32 @@ def external_login_or_signup(request,
else:
auth_backend = 'django.contrib.auth.backends.ModelBackend'
user.backend = auth_backend
log.info('SHIB: Logging in linked user %s', user.email)
AUDIT_LOG.info('Linked user "%s" logged in via Shibboleth', user.email)
else:
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 signup(request, eamap)
# we want to log the failure, but don't want to log the password attempted:
AUDIT_LOG.warning('External Auth Login failed for "%s"', uname)
return _signup(request, eamap)
if not user.is_active:
log.warning("User %s is not active", uname)
AUDIT_LOG.warning('User "%s" is not active after external login', 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)
# Now to try enrollment
# Need to special case Shibboleth here because it logs in via a GET.
# testing request.method for extra paranoia
if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and 'shib:' in external_domain and request.method == 'GET':
enroll_request = make_shib_enrollment_request(request)
if uses_shibboleth 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 - %s (%s)", user.username, user.email)
AUDIT_LOG.info("Login success - %s (%s)", user.username, user.email)
if retfun is None:
return redirect('/')
return retfun()
@@ -217,20 +222,16 @@ def external_login_or_signup(request,
@ensure_csrf_cookie
@cache_if_anonymous
def signup(request, eamap=None):
def _signup(request, eamap):
"""
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 ExteralAuthMap object, specifying the external user
eamap is an ExternalAuthMap object, specifying the external user
for which to complete the signup.
"""
if eamap is None:
pass
# save this for use by student.views.create_account
request.session['ExternalAuthMap'] = eamap
@@ -248,8 +249,9 @@ def signup(request, eamap=None):
# Some openEdX instances can't have terms of service for shib users, like
# according to Stanford's Office of General Counsel
if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and settings.MITX_FEATURES.get('SHIB_DISABLE_TOS') and \
('shib' in eamap.external_domain):
uses_shibboleth = (settings.MITX_FEATURES.get('AUTH_USE_SHIB') and
eamap.external_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX))
if uses_shibboleth and settings.MITX_FEATURES.get('SHIB_DISABLE_TOS'):
context['ask_for_tos'] = False
# detect if full name is blank and ask for it from user
@@ -272,19 +274,19 @@ def signup(request, eamap=None):
# -----------------------------------------------------------------------------
def ssl_dn_extract_info(dn):
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)
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)
ss = re.search('/CN=([^/]+)/', dn_string)
if ss:
fullname = ss.group(1)
else:
@@ -292,7 +294,7 @@ def ssl_dn_extract_info(dn):
return (user, email, fullname)
def ssl_get_cert_from_request(request):
def _ssl_get_cert_from_request(request):
"""
Extract user information from certificate, if it exists, returning (user, email, fullname).
Else return None.
@@ -311,9 +313,6 @@ def ssl_get_cert_from_request(request):
return cert
(user, email, fullname) = ssl_dn_extract_info(cert)
return (user, email, fullname)
def ssl_login_shortcut(fn):
"""
@@ -324,24 +323,26 @@ def ssl_login_shortcut(fn):
if not settings.MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES']:
return fn(*args, **kwargs)
request = args[0]
cert = ssl_get_cert_from_request(request)
cert = _ssl_get_cert_from_request(request)
if not cert: # no certificate information - show normal login window
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)
(_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
)
return wrapped
@csrf_exempt
def ssl_login(request):
"""
This is called by student.views.index when
This is called by branding.views.index when
MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True
Used for MIT user authentication. This presumes the web server
@@ -355,22 +356,28 @@ def ssl_login(request):
Else continues on with student.views.index, and no authentication.
"""
cert = ssl_get_cert_from_request(request)
# Just to make sure we're calling this only at MIT:
if not settings.MITX_FEATURES['AUTH_USE_MIT_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)
(_user, email, fullname) = _ssl_dn_extract_info(cert)
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)
return _external_login_or_signup(
request,
external_id=email,
external_domain="ssl:MIT",
credentials=cert,
email=email,
fullname=fullname,
retfun=retfun
)
# -----------------------------------------------------------------------------
@@ -396,28 +403,30 @@ def shib_login(request):
log.error("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
# If we get here, the user has authenticated properly
shib = {attr: request.META.get(attr, '')
for attr in ['REMOTE_USER', 'givenName', 'sn', 'mail', 'Shib-Identity-Provider']}
#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
# 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().decode('utf-8')
shib['givenName'] = shib['givenName'].split(";")[0].strip().capitalize().decode('utf-8')
# TODO: should we be logging creds here, at info level?
log.info("SHIB creds returned: %r", shib)
return external_login_or_signup(request,
external_id=shib['REMOTE_USER'],
external_domain="shib:" + shib['Shib-Identity-Provider'],
credentials=shib,
email=shib['mail'],
fullname=u'%s %s' % (shib['givenName'], shib['sn']),
)
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=u'%s %s' % (shib['givenName'], shib['sn']),
)
def make_shib_enrollment_request(request):
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
@@ -452,14 +461,14 @@ def course_specific_login(request, course_id):
try:
course = course_from_id(course_id)
except ItemNotFoundError:
#couldn't find the course, will just return vanilla signin page
# 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:
# now the dispatching conditionals. Only shib for now
if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX):
return redirect_with_querystring('shib-login', query_string)
#Default fallthrough to normal signin page
# Default fallthrough to normal signin page
return redirect_with_querystring('signin_user', query_string)
@@ -473,15 +482,15 @@ def course_specific_register(request, course_id):
try:
course = course_from_id(course_id)
except ItemNotFoundError:
#couldn't find the course, will just return vanilla registration page
# 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
# now the dispatching conditionals. Only shib for now
if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX):
# shib-login takes care of both registration and login flows
return redirect_with_querystring('shib-login', query_string)
#Default fallthrough to normal registration page
# Default fallthrough to normal registration page
return redirect_with_querystring('register_user', query_string)
@@ -702,7 +711,7 @@ def provider_login(request):
except User.DoesNotExist:
request.session['openid_error'] = True
msg = "OpenID login failed - Unknown user email: %s"
log.warning(msg, email)
AUDIT_LOG.warning(msg, email)
return HttpResponseRedirect(openid_request_url)
# attempt to authenticate user (but not actually log them in...)
@@ -713,7 +722,7 @@ def provider_login(request):
if user is None:
request.session['openid_error'] = True
msg = "OpenID login failed - password for %s is invalid"
log.warning(msg, email)
AUDIT_LOG.warning(msg, email)
return HttpResponseRedirect(openid_request_url)
# authentication succeeded, so fetch user information
@@ -723,8 +732,8 @@ def provider_login(request):
if 'openid_error' in request.session:
del request.session['openid_error']
log.info("OpenID login success - %s (%s)",
user.username, user.email)
AUDIT_LOG.info("OpenID login success - %s (%s)",
user.username, user.email)
# redirect user to return_to location
url = endpoint + urlquote(user.username)
@@ -755,7 +764,7 @@ def provider_login(request):
# the account is not active, so redirect back to the login page:
request.session['openid_error'] = True
msg = "Login failed - Account not active for user %s"
log.warning(msg, username)
AUDIT_LOG.warning(msg, username)
return HttpResponseRedirect(openid_request_url)
# determine consumer domain if applicable
@@ -856,9 +865,11 @@ def test_center_login(request):
try:
testcenteruser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id)
except TestCenterUser.DoesNotExist:
log.error("not able to find demographics for cand ID {}".format(client_candidate_id))
AUDIT_LOG.error("not able to find demographics for cand ID {}".format(client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID"))
AUDIT_LOG.info("Attempting to log in test-center user '{}' for test of cand {}".format(testcenteruser.user.username, client_candidate_id))
# find testcenter_registration that matches the provided exam code:
# Note that we could rely in future on either the registrationId or the exam code,
# or possibly both. But for now we know what to do with an ExamSeriesCode,
@@ -867,13 +878,13 @@ def test_center_login(request):
# we are not allowed to make up a new error code, according to Pearson,
# so instead of "missingExamSeriesCode", we use a valid one that is
# inaccurate but at least distinct. (Sigh.)
log.error("missing exam series code for cand ID {}".format(client_candidate_id))
AUDIT_LOG.error("missing exam series code for cand ID {}".format(client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID"))
exam_series_code = request.POST.get('vueExamSeriesCode')
registrations = TestCenterRegistration.objects.filter(testcenter_user=testcenteruser, exam_series_code=exam_series_code)
if not registrations:
log.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id))
AUDIT_LOG.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned"))
# TODO: figure out what to do if there are more than one registrations....
@@ -883,14 +894,14 @@ def test_center_login(request):
course_id = registration.course_id
course = course_from_id(course_id) # assume it will be found....
if not course:
log.error("not able to find course from ID {} for cand ID {}".format(course_id, client_candidate_id))
AUDIT_LOG.error("not able to find course from ID {} for cand ID {}".format(course_id, client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests"))
exam = course.get_test_center_exam(exam_series_code)
if not exam:
log.error("not able to find exam {} for course ID {} and cand ID {}".format(exam_series_code, course_id, client_candidate_id))
AUDIT_LOG.error("not able to find exam {} for course ID {} and cand ID {}".format(exam_series_code, course_id, client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests"))
location = exam.exam_url
log.info("proceeding with test of cand {} on exam {} for course {}: URL = {}".format(client_candidate_id, exam_series_code, course_id, location))
log.info("Proceeding with test of cand {} on exam {} for course {}: URL = {}".format(client_candidate_id, exam_series_code, course_id, location))
# check if the test has already been taken
timelimit_descriptor = modulestore().get_instance(course_id, Location(location))
@@ -907,7 +918,7 @@ def test_center_login(request):
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram"))
if timelimit_module and timelimit_module.has_ended:
log.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at))
AUDIT_LOG.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at))
return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken"))
# check if we need to provide an accommodation:
@@ -922,7 +933,7 @@ def test_center_login(request):
if time_accommodation_code:
timelimit_module.accommodation_code = time_accommodation_code
log.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code))
AUDIT_LOG.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code))
# UGLY HACK!!!
# Login assumes that authentication has occurred, and that there is a
@@ -936,6 +947,7 @@ def test_center_login(request):
# testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__)
testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass")
login(request, testcenteruser.user)
AUDIT_LOG.info("Logged in user '{}' for test of cand {} on exam {} for course {}: URL = {}".format(testcenteruser.user.username, client_candidate_id, exam_series_code, course_id, location))
# And start the test:
return jump_to(request, course_id, location)