[#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):