[#8278] track IP/UA of past logins, backfill from certain audit log entries. Customizable with auth provider methods
diff --git a/Allura/allura/lib/plugin.py b/Allura/allura/lib/plugin.py
index 8d2434e..9156f79 100644
--- a/Allura/allura/lib/plugin.py
+++ b/Allura/allura/lib/plugin.py
@@ -194,6 +194,7 @@
else:
self.session['username'] = user.username
h.auditlog_user('Successful login', user=user)
+ user.backfill_login_details(self)
self.after_login(user, self.request)
if 'rememberme' in self.request.params:
@@ -203,6 +204,7 @@
self.session['login_expires'] = True
self.session.save()
g.statsUpdater.addUserLogin(user)
+ user.add_login_detail(self.get_login_detail(self.request))
user.track_login(self.request)
# set a non-secure cookie with same expiration as session,
# so an http request can know if there is a related session on https
@@ -374,6 +376,46 @@
def hibp_password_check_enabled(self):
return asbool(tg.config.get('auth.hibp_password_check', False))
+ @property
+ def trusted_auditlog_line_prefixes(self):
+ return [
+ "Successful login", # this is the main one
+ # all others are to include login activity before mid-2017 when "Successful login" logs were introduced:
+ "Primary email changed",
+ "New email address:",
+ "Display Name changed",
+ "Email address verified:",
+ "Password changed",
+ "Email address deleted:",
+ "Account activated",
+ "Phone verification succeeded.",
+ "Visited multifactor new TOTP page",
+ "Set up multifactor TOTP",
+ "Viewed multifactor TOTP config page",
+ "Viewed multifactor recovery codes",
+ "Regenerated multifactor recovery codes",
+ ]
+
+ def login_details_from_auditlog(self, auditlog):
+ ip = ua = None
+ matches = re.search(r'^IP Address: (.+)\n', auditlog.message, re.MULTILINE)
+ if matches:
+ ip = matches.group(1)
+ matches = re.search(r'^User-Agent: (.+)\n', auditlog.message, re.MULTILINE)
+ if matches:
+ ua = matches.group(1)
+ if ua or ip:
+ return dict(
+ ip=ip,
+ ua=ua,
+ )
+
+ def get_login_detail(self, request):
+ return dict(
+ ip=utils.ip_address(request),
+ ua=request.headers.get('User-Agent'),
+ )
+
class LocalAuthenticationProvider(AuthenticationProvider):
diff --git a/Allura/allura/model/auth.py b/Allura/allura/model/auth.py
index b63a89c..44ed66d 100644
--- a/Allura/allura/model/auth.py
+++ b/Allura/allura/model/auth.py
@@ -297,6 +297,11 @@
session_date=S.DateTime,
session_ip=str,
session_ua=str))
+ previous_login_details = FieldProperty([{
+ str: S.Anything
+ # "ip" and "ua" are standard fields
+ # but allow for anything to be stored, like other headers, geo info, frequency of use, etc
+ }])
def index(self):
provider = plugin.AuthenticationProvider.get(None) # no need in request here
@@ -360,6 +365,26 @@
self.last_access['session_ua'] = user_agent
session(self).flush(self)
+ def add_login_detail(self, detail):
+ if detail not in self.previous_login_details:
+ self.previous_login_details.append(detail)
+
+ def backfill_login_details(self, auth_provider):
+ if self.previous_login_details:
+ return
+ # ".*" at start of regex and the DOTALL flag is needed only for the test, which uses mim
+ # Fixed in ming f9f69d3c, so once we upgrade to 0.6.1+ we can remove it
+ msg_regex = re.compile(r'.*^({})'.format('|'.join([re.escape(line_prefix)
+ for line_prefix
+ in auth_provider.trusted_auditlog_line_prefixes])),
+ re.MULTILINE | re.DOTALL)
+ for auditlog in AuditLog.for_user(self, message=msg_regex):
+ if not msg_regex.search(auditlog.message):
+ continue
+ login_detail = auth_provider.login_details_from_auditlog(auditlog)
+ if login_detail:
+ self.add_login_detail(login_detail)
+
def can_send_user_message(self):
"""Return true if User is permitted to send a mesage to another user.
@@ -934,8 +959,8 @@
return cls(project_id=pid, user_id=user._id, url=url, message=message)
@classmethod
- def for_user(cls, user):
- return cls.query.find(dict(project_id=None, user_id=user._id))
+ def for_user(cls, user, **kwargs):
+ return cls.query.find(dict(project_id=None, user_id=user._id, **kwargs))
@classmethod
def log_user(cls, message, *args, **kwargs):
diff --git a/Allura/allura/tests/model/test_auth.py b/Allura/allura/tests/model/test_auth.py
index 79234e2..0f03acb 100644
--- a/Allura/allura/tests/model/test_auth.py
+++ b/Allura/allura/tests/model/test_auth.py
@@ -20,7 +20,6 @@
"""
Model tests for auth
"""
-
from nose.tools import (
with_setup,
assert_equal,
@@ -29,7 +28,7 @@
assert_not_in,
assert_in,
)
-from tg import tmpl_context as c, app_globals as g
+from tg import tmpl_context as c, app_globals as g, request
from webob import Request
from mock import patch, Mock
from datetime import datetime, timedelta
@@ -38,6 +37,7 @@
from ming.odm import session
from allura import model as M
+from allura.lib import helpers as h
from allura.lib import plugin
from allura.tests import decorators as td
from alluratest.controller import setup_basic_test, setup_global_objects, setup_functional_test
@@ -405,6 +405,7 @@
# provided bby auth provider
assert_in('user_registration_date_dt', idx)
+
@with_setup(setUp)
def test_user_index_none_values():
c.user.email_addresses = [None]
@@ -414,3 +415,28 @@
assert_equal(idx['email_addresses_t'], '')
assert_equal(idx['telnumbers_t'], '')
assert_equal(idx['webpages_t'], '')
+
+
+@with_setup(setUp)
+def test_user_backfill_login_details():
+ with h.push_config(request, user_agent='TestBrowser/55'):
+ # these shouldn't match
+ h.auditlog_user('something happened')
+ h.auditlog_user('blah blah Password changed')
+ with h.push_config(request, user_agent='TestBrowser/56'):
+ # these should all match, but only one entry created for this ip/ua
+ h.auditlog_user('Account activated')
+ h.auditlog_user('Successful login')
+ h.auditlog_user('Password changed')
+ with h.push_config(request, user_agent='TestBrowser/57'):
+ # this should match too
+ h.auditlog_user('Set up multifactor TOTP')
+ ThreadLocalORMSession.flush_all()
+
+ auth_provider = plugin.AuthenticationProvider.get(None)
+ c.user.backfill_login_details(auth_provider)
+
+ assert_equal(sorted(c.user.previous_login_details), [
+ {'ip': '127.0.0.1', 'ua': 'TestBrowser/56'},
+ {'ip': '127.0.0.1', 'ua': 'TestBrowser/57'},
+ ])
diff --git a/Allura/allura/tests/test_plugin.py b/Allura/allura/tests/test_plugin.py
index 8ccf5d5..c7bd22b 100644
--- a/Allura/allura/tests/test_plugin.py
+++ b/Allura/allura/tests/test_plugin.py
@@ -599,6 +599,40 @@
ThreadLocalORMSession.flush_all()
assert_equal(user.disabled, True)
+ def test_login_details_from_auditlog(self):
+ assert_equal(self.provider.login_details_from_auditlog(M.AuditLog(
+ message='')),
+ None)
+
+ assert_equal(self.provider.login_details_from_auditlog(M.AuditLog(
+ message='IP Address: 1.2.3.4\nFoo')),
+ dict(ip='1.2.3.4', ua=None))
+
+ assert_equal(self.provider.login_details_from_auditlog(M.AuditLog(
+ message='Foo\nIP Address: 1.2.3.4\nFoo')),
+ dict(ip='1.2.3.4', ua=None))
+
+ assert_equal(self.provider.login_details_from_auditlog(M.AuditLog(
+ message='blah blah IP Address: 1.2.3.4\nFoo')),
+ None)
+
+ assert_equal(self.provider.login_details_from_auditlog(M.AuditLog(
+ message='User-Agent: Mozilla/Firefox\nFoo')),
+ dict(ip=None, ua='Mozilla/Firefox'))
+
+ assert_equal(self.provider.login_details_from_auditlog(M.AuditLog(
+ message='IP Address: 1.2.3.4\nUser-Agent: Mozilla/Firefox\nFoo')),
+ dict(ip='1.2.3.4', ua='Mozilla/Firefox'))
+
+ def test_get_login_detail(self):
+ assert_equal(self.provider.get_login_detail(Request.blank('/')),
+ dict(ip=None, ua=None))
+
+ assert_equal(self.provider.get_login_detail(Request.blank('/',
+ headers={'User-Agent': 'mybrowser'},
+ environ={'REMOTE_ADDR': '3.3.3.3'})),
+ dict(ip='3.3.3.3', ua='mybrowser'))
+
class TestAuthenticationProvider(object):