You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by br...@apache.org on 2019/04/25 18:55:50 UTC

[allura] branch db/8278 created (now a80b15a)

This is an automated email from the ASF dual-hosted git repository.

brondsem pushed a change to branch db/8278
in repository https://gitbox.apache.org/repos/asf/allura.git.


      at a80b15a  [#8278] track IP/UA of past logins, backfill from certain audit log entries.  Customizable with auth provider methods

This branch includes the following new commits:

     new a80b15a  [#8278] track IP/UA of past logins, backfill from certain audit log entries.  Customizable with auth provider methods

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



[allura] 01/01: [#8278] track IP/UA of past logins, backfill from certain audit log entries. Customizable with auth provider methods

Posted by br...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

brondsem pushed a commit to branch db/8278
in repository https://gitbox.apache.org/repos/asf/allura.git

commit a80b15ab9a3cb76c400e2e793cab52e3b2809911
Author: Dave Brondsema <da...@brondsema.net>
AuthorDate: Thu Apr 25 13:26:39 2019 -0400

    [#8278] track IP/UA of past logins, backfill from certain audit log entries.  Customizable with auth provider methods
---
 Allura/allura/lib/plugin.py            | 42 ++++++++++++++++++++++++++++++++++
 Allura/allura/model/auth.py            | 29 +++++++++++++++++++++--
 Allura/allura/tests/model/test_auth.py | 30 ++++++++++++++++++++++--
 Allura/allura/tests/test_plugin.py     | 34 +++++++++++++++++++++++++++
 4 files changed, 131 insertions(+), 4 deletions(-)

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 @@ class AuthenticationProvider(object):
         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 @@ class AuthenticationProvider(object):
             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 @@ class AuthenticationProvider(object):
     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 @@ class User(MappedClass, ActivityNode, ActivityObject, SearchIndexable):
         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 @@ class User(MappedClass, ActivityNode, ActivityObject, SearchIndexable):
             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 @@ class AuditLog(object):
         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 @@ from nose.tools import (
     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.orm.ormsession import ThreadLocalORMSession
 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 @@ def test_user_index():
     # 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 @@ def test_user_index_none_values():
     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 @@ class TestLocalAuthenticationProvider(object):
             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):