Commit 635d36fc by Brian Wilson

Add audit log definition, and use for logging of logins in external_auth and…

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.)
parent 50ef0d1c
......@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
Common: Add additional logging to cover login attempts and logouts.
Studio: Send e-mails to new Studio users (on edge only) when their course creator
status has changed. This will not be in use until the course creator table
is enabled.
......
......@@ -20,6 +20,7 @@ from random import randint
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
......@@ -30,6 +31,7 @@ from pytz import UTC
log = logging.getLogger(__name__)
AUDIT_LOG = logging.getLogger("audit")
class UserProfile(models.Model):
......@@ -779,3 +781,20 @@ def update_user_information(sender, instance, created, **kwargs):
log = logging.getLogger("mitx.discussion")
log.error(unicode(e))
log.error("update user info to discussion failed for user with id: " + str(instance.id))
# Define login and logout handlers here in the models file, instead of the views file,
# so that they are more likely to be loaded when a Studio user brings up the Studio admin
# page to login. These are currently the only signals available, so we need to continue
# identifying and logging failures separately (in views).
@receiver(user_logged_in)
def log_successful_login(sender, request, user, **kwargs):
"""Handler to log when logins have occurred successfully."""
AUDIT_LOG.info(u"Login success - {0} ({1})".format(user.username, user.email))
@receiver(user_logged_out)
def log_successful_logout(sender, request, user, **kwargs):
"""Handler to log when logouts have occurred successfully."""
AUDIT_LOG.info(u"Logout - {0}".format(request.user))
'''
Tests for student activation and login
'''
import json
from mock import patch
from django.test import TestCase
from django.test.client import Client
from django.core.urlresolvers import reverse
from courseware.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory
import json
from django.core.urlresolvers import reverse, NoReverseMatch
from student.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory
class LoginTest(TestCase):
......@@ -29,30 +31,37 @@ class LoginTest(TestCase):
self.client = Client()
# Store the login url
try:
self.url = reverse('login_post')
except NoReverseMatch:
self.url = reverse('login')
def test_login_success(self):
response = self._login_response('test@edx.org', 'test_password')
response, mock_audit_log = self._login_response('test@edx.org', 'test_password', patched_audit_log='student.models.AUDIT_LOG')
self._assert_response(response, success=True)
self._assert_audit_log(mock_audit_log, 'info', [u'Login success', u'test@edx.org'])
def test_login_success_unicode_email(self):
unicode_email = u'test@edx.org' + unichr(40960)
unicode_email = u'test' + unichr(40960) + u'@edx.org'
self.user.email = unicode_email
self.user.save()
response = self._login_response(unicode_email, 'test_password')
response, mock_audit_log = self._login_response(unicode_email, 'test_password', patched_audit_log='student.models.AUDIT_LOG')
self._assert_response(response, success=True)
self._assert_audit_log(mock_audit_log, 'info', [u'Login success', unicode_email])
def test_login_fail_no_user_exists(self):
response = self._login_response('not_a_user@edx.org', 'test_password')
nonexistent_email = u'not_a_user@edx.org'
response, mock_audit_log = self._login_response(nonexistent_email, 'test_password')
self._assert_response(response, success=False,
value='Email or password is incorrect')
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Unknown user email', nonexistent_email])
def test_login_fail_wrong_password(self):
response = self._login_response('test@edx.org', 'wrong_password')
response, mock_audit_log = self._login_response('test@edx.org', 'wrong_password')
self._assert_response(response, success=False,
value='Email or password is incorrect')
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'password for', u'test@edx.org', u'invalid'])
def test_login_not_activated(self):
# De-activate the user
......@@ -60,24 +69,38 @@ class LoginTest(TestCase):
self.user.save()
# Should now be unable to login
response = self._login_response('test@edx.org', 'test_password')
response, mock_audit_log = self._login_response('test@edx.org', 'test_password')
self._assert_response(response, success=False,
value="This account has not been activated")
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Account not active for user'])
def test_login_unicode_email(self):
unicode_email = u'test@edx.org' + unichr(40960)
response = self._login_response(unicode_email, 'test_password')
response, mock_audit_log = self._login_response(unicode_email, 'test_password')
self._assert_response(response, success=False)
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', unicode_email])
def test_login_unicode_password(self):
unicode_password = u'test_password' + unichr(1972)
response = self._login_response('test@edx.org', unicode_password)
response, mock_audit_log = self._login_response('test@edx.org', unicode_password)
self._assert_response(response, success=False)
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'password for', u'test@edx.org', u'invalid'])
def test_logout_logging(self):
response, _ = self._login_response('test@edx.org', 'test_password')
self._assert_response(response, success=True)
logout_url = reverse('logout')
with patch('student.models.AUDIT_LOG') as mock_audit_log:
response = self.client.post(logout_url)
self.assertEqual(response.status_code, 302)
self._assert_audit_log(mock_audit_log, 'info', [u'Logout', u'test'])
def _login_response(self, email, password):
def _login_response(self, email, password, patched_audit_log='student.views.AUDIT_LOG'):
''' Post the login info '''
post_params = {'email': email, 'password': password}
return self.client.post(self.url, post_params)
with patch(patched_audit_log) as mock_audit_log:
result = self.client.post(self.url, post_params)
return result, mock_audit_log
def _assert_response(self, response, success=None, value=None):
'''
......@@ -105,3 +128,16 @@ class LoginTest(TestCase):
msg = ("'%s' did not contain '%s'" %
(str(response_dict['value']), str(value)))
self.assertTrue(value in response_dict['value'], msg)
def _assert_audit_log(self, mock_audit_log, level, log_strings):
"""
Check that the audit log has received the expected call.
"""
method_calls = mock_audit_log.method_calls
self.assertEquals(len(method_calls), 1)
name, args, _kwargs = method_calls[0]
self.assertEquals(name, level)
self.assertEquals(len(args), 1)
format_string = args[0]
for log_string in log_strings:
self.assertIn(log_string, format_string)
......@@ -56,6 +56,8 @@ from statsd import statsd
from pytz import UTC
log = logging.getLogger("mitx.student")
AUDIT_LOG = logging.getLogger("audit")
Article = namedtuple('Article', 'title url author image deck publication publish_date')
......@@ -107,8 +109,7 @@ day_pattern = re.compile(r'\s\d+,\s')
multimonth_pattern = re.compile(r'\s?\-\s?\S+\s')
def get_date_for_press(publish_date):
import datetime
def _get_date_for_press(publish_date):
# strip off extra months, and just use the first:
date = re.sub(multimonth_pattern, ", ", publish_date)
if re.search(day_pattern, date):
......@@ -129,7 +130,7 @@ def press(request):
json_articles = json.loads(content)
cache.set("student_press_json_articles", json_articles)
articles = [Article(**article) for article in json_articles]
articles.sort(key=lambda item: get_date_for_press(item.publish_date), reverse=True)
articles.sort(key=lambda item: _get_date_for_press(item.publish_date), reverse=True)
return render_to_response('static_templates/press.html', {'articles': articles})
......@@ -233,7 +234,7 @@ def signin_user(request):
@ensure_csrf_cookie
def register_user(request, extra_context={}):
def register_user(request, extra_context=None):
"""
This view will display the non-modal registration form
"""
......@@ -244,6 +245,7 @@ def register_user(request, extra_context={}):
'course_id': request.GET.get('course_id'),
'enrollment_action': request.GET.get('enrollment_action')
}
if extra_context is not None:
context.update(extra_context)
return render_to_response('register.html', context)
......@@ -381,7 +383,7 @@ def change_enrollment(request):
"run:{0}".format(run)])
try:
enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id)
enrollment, _created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id)
except IntegrityError:
# If we've already created this enrollment in a separate transaction,
# then just continue
......@@ -425,19 +427,21 @@ def login_user(request, error=""):
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
log.warning(u"Login failed - Unknown user email: {0}".format(email))
AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email))
return HttpResponse(json.dumps({'success': False,
'value': _('Email or password is incorrect.')})) # TODO: User error message
username = user.username
user = authenticate(username=username, password=password)
if user is None:
log.warning(u"Login failed - password for {0} is invalid".format(email))
AUDIT_LOG.warning(u"Login failed - password for {0} is invalid".format(email))
return HttpResponse(json.dumps({'success': False,
'value': _('Email or password is incorrect.')}))
if user is not None and user.is_active:
try:
# We do not log here, because we have a handler registered
# to perform logging on successful logins.
login(request, user)
if request.POST.get('remember') == 'true':
request.session.set_expiry(604800)
......@@ -445,14 +449,14 @@ def login_user(request, error=""):
else:
request.session.set_expiry(0)
except Exception as e:
AUDIT_LOG.critical("Login failed - Could not create session. Is memcached running?")
log.critical("Login failed - Could not create session. Is memcached running?")
log.exception(e)
log.info(u"Login success - {0} ({1})".format(username, email))
raise
try_change_enrollment(request)
statsd.increment(_("common.student.successful_login"))
statsd.increment("common.student.successful_login")
response = HttpResponse(json.dumps({'success': True}))
# set the login cookie for the edx marketing site
......@@ -476,7 +480,7 @@ def login_user(request, error=""):
return response
log.warning(u"Login failed - Account not active for user {0}, resending activation".format(username))
AUDIT_LOG.warning(u"Login failed - Account not active for user {0}, resending activation".format(username))
reactivation_email_for_user(user)
not_activated_msg = _("This account has not been activated. We have sent another activation message. Please check your e-mail for the activation instructions.")
......@@ -491,7 +495,8 @@ def logout_user(request):
Deletes both the CSRF and sessionid cookies so the marketing
site can determine the logged in state of the user
'''
# We do not log here, because we have a handler registered
# to perform logging on successful logouts.
logout(request)
response = redirect('/')
response.delete_cookie(settings.EDXMKTG_COOKIE_NAME,
......@@ -598,7 +603,7 @@ def create_account(request, post_override=None):
password = eamap.internal_password
post_vars = dict(post_vars.items())
post_vars.update(dict(email=email, name=name, password=password))
log.info('In create_account with external_auth: post_vars = %s' % post_vars)
log.debug(u'In create_account with external_auth: user = %s, email=%s', name, email)
# Confirm we have a properly formed request
for a in ['username', 'email', 'password', 'name']:
......@@ -684,7 +689,7 @@ def create_account(request, post_override=None):
'-' * 80 + '\n\n' + message)
send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [dest_addr], fail_silently=False)
else:
res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
_res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
except:
log.warning('Unable to send activation email to user', exc_info=True)
js['value'] = _('Could not send activation e-mail.')
......@@ -697,17 +702,23 @@ def create_account(request, post_override=None):
login(request, login_user)
request.session.set_expiry(0)
# TODO: there is no error checking here to see that the user actually logged in successfully,
# and is not yet an active user.
if login_user is not None:
AUDIT_LOG.info(u"Login success on new account creation - {0}".format(login_user.username))
if DoExternalAuth:
eamap.user = login_user
eamap.dtsignup = datetime.datetime.now(UTC)
eamap.save()
log.info("User registered with external_auth %s" % post_vars['username'])
log.info('Updated ExternalAuthMap for %s to be %s' % (post_vars['username'], eamap))
AUDIT_LOG.info("User registered with external_auth %s", post_vars['username'])
AUDIT_LOG.info('Updated ExternalAuthMap for %s to be %s', post_vars['username'], eamap)
if settings.MITX_FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'):
log.info('bypassing activation email')
login_user.is_active = True
login_user.save()
AUDIT_LOG.info(u"Login activated on extauth account - {0} ({1})".format(login_user.username, login_user.email))
try_change_enrollment(request)
......@@ -964,14 +975,14 @@ def activate_account(request, key):
r[0].activate()
already_active = False
#Enroll student in any pending courses he/she may have if auto_enroll flag is set
# Enroll student in any pending courses he/she may have if auto_enroll flag is set
student = User.objects.filter(id=r[0].user_id)
if student:
ceas = CourseEnrollmentAllowed.objects.filter(email=student[0].email)
for cea in ceas:
if cea.auto_enroll:
course_id = cea.course_id
enrollment, created = CourseEnrollment.objects.get_or_create(user_id=student[0].id, course_id=course_id)
_enrollment, _created = CourseEnrollment.objects.get_or_create(user_id=student[0].id, course_id=course_id)
resp = render_to_response("registration/activation_complete.html", {'user_logged_in': user_logged_in, 'already_active': already_active})
return resp
......@@ -1003,7 +1014,7 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None):
''' A wrapper around django.contrib.auth.views.password_reset_confirm.
Needed because we want to set the user as active at this step.
'''
#cribbed from django.contrib.auth.views.password_reset_confirm
# cribbed from django.contrib.auth.views.password_reset_confirm
try:
uid_int = base36_to_int(uidb36)
user = User.objects.get(id=uid_int)
......@@ -1029,7 +1040,7 @@ def reactivation_email_for_user(user):
message = render_to_string('emails/activation_email.txt', d)
try:
res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
_res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
except:
log.warning('Unable to send reactivation email', exc_info=True)
return HttpResponse(json.dumps({'success': False, 'error': _('Unable to send reactivation email')}))
......@@ -1087,7 +1098,7 @@ def change_email_request(request):
subject = ''.join(subject.splitlines())
message = render_to_string('emails/email_change.txt', d)
res = send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [pec.new_email])
_res = send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [pec.new_email])
return HttpResponse(json.dumps({'success': True}))
......
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