[#8290] move previous_login_details into UserLoginDetails collection
diff --git a/Allura/allura/command/show_models.py b/Allura/allura/command/show_models.py
index ac2ef86..86180d4 100644
--- a/Allura/allura/command/show_models.py
+++ b/Allura/allura/command/show_models.py
@@ -211,7 +211,7 @@
     def command(self):
         from allura import model as M
         main_session_classes = [M.main_orm_session, M.repository_orm_session,
-                                M.task_orm_session]
+                                M.task_orm_session, M.main_explicitflush_orm_session]
         if asbool(self.config.get('activitystream.recording.enabled', False)):
             from activitystream.storage.mingstorage import activity_odm_session
             main_session_classes.append(activity_odm_session)
diff --git a/Allura/allura/lib/plugin.py b/Allura/allura/lib/plugin.py
index 73786a0..3f9769a 100644
--- a/Allura/allura/lib/plugin.py
+++ b/Allura/allura/lib/plugin.py
@@ -187,7 +187,7 @@
         else:
             self.session.pop('multifactor-username', None)
 
-        login_details = self.get_login_detail(self.request)
+        login_details = self.get_login_detail(self.request, user)
 
         expire_reason = None
         if self.is_password_expired(user):
@@ -436,6 +436,7 @@
         ]
 
     def login_details_from_auditlog(self, auditlog):
+        from allura import model as M
         ip = ua = None
         matches = re.search(r'^IP Address: (.+)\n', auditlog.message, re.MULTILINE)
         if matches:
@@ -444,20 +445,24 @@
         if matches:
             ua = matches.group(1)
         if ua or ip:
-            return dict(
+            return M.UserLoginDetails(
+                user_id=auditlog.user_id,
                 ip=ip,
                 ua=ua,
             )
 
-    def get_login_detail(self, request):
-        return dict(
+    def get_login_detail(self, request, user):
+        from allura import model as M
+        return M.UserLoginDetails(
+            user_id=user._id,
             ip=utils.ip_address(request),
             ua=request.headers.get('User-Agent'),
         )
 
     def trusted_login_source(self, user, login_details):
         # TODO: could also factor in User-Agent but hard to know what parts of the UA are meaningful to check here
-        for prev_login in user.previous_login_details:
+        from allura import model as M
+        for prev_login in M.UserLoginDetails.query.find({'user_id': user._id}):
             if prev_login['ip'] == login_details['ip']:
                 return 'exact ip'
             if asbool(tg.config.get('auth.trust_ip_3_octets_match', False)) and \
diff --git a/Allura/allura/model/__init__.py b/Allura/allura/model/__init__.py
index 0b1bea2..b5bbe1b 100644
--- a/Allura/allura/model/__init__.py
+++ b/Allura/allura/model/__init__.py
@@ -27,7 +27,7 @@
 from .discuss import Discussion, Thread, PostHistory, Post, DiscussionAttachment
 from .attachments import BaseAttachment
 from .auth import AuthGlobals, User, ProjectRole, EmailAddress, OldProjectRole
-from .auth import AuditLog, audit_log, AlluraUserProperty
+from .auth import AuditLog, audit_log, AlluraUserProperty, UserLoginDetails
 from .filesystem import File
 from .notification import Notification, Mailbox, SiteNotification
 from .repository import Repository, RepositoryImplementation
@@ -39,7 +39,7 @@
 from .multifactor import TotpKey
 
 from .types import ACE, ACL, EVERYONE, ALL_PERMISSIONS, DENY_ALL, MarkdownCache
-from .session import main_doc_session, main_orm_session
+from .session import main_doc_session, main_orm_session, main_explicitflush_orm_session
 from .session import project_doc_session, project_orm_session
 from .session import artifact_orm_session, repository_orm_session
 from .session import task_orm_session
@@ -61,4 +61,4 @@
     'OAuthRequestToken', 'OAuthAccessToken', 'MonQTask', 'Webhook', 'ACE', 'ACL', 'EVERYONE', 'ALL_PERMISSIONS',
     'DENY_ALL', 'MarkdownCache', 'main_doc_session', 'main_orm_session', 'project_doc_session', 'project_orm_session',
     'artifact_orm_session', 'repository_orm_session', 'task_orm_session', 'ArtifactSessionExtension', 'repository',
-    'repo_refresh', 'SiteNotification', 'TotpKey']
+    'repo_refresh', 'SiteNotification', 'TotpKey', 'UserLoginDetails', 'main_explicitflush_orm_session']
diff --git a/Allura/allura/model/auth.py b/Allura/allura/model/auth.py
index 3520dcd..ce429b7 100644
--- a/Allura/allura/model/auth.py
+++ b/Allura/allura/model/auth.py
@@ -16,17 +16,17 @@
 #       under the License.
 
 import logging
-import urllib
 import calendar
 from urlparse import urlparse
 from email import header
 from hashlib import sha256
 from datetime import timedelta, datetime, time
-
 import os
 import re
+
 from pytz import timezone
 import pymongo
+from pymongo.errors import DuplicateKeyError
 from tg import config
 from tg import tmpl_context as c, app_globals as g
 from tg import request
@@ -44,7 +44,7 @@
 from allura.lib import utils
 from allura.lib.decorators import memoize
 from allura.lib.search import SearchIndexable
-from .session import main_orm_session, main_doc_session
+from .session import main_orm_session, main_doc_session, main_explicitflush_orm_session
 from .session import project_orm_session
 from .timeline import ActivityNode, ActivityObject
 
@@ -297,11 +297,6 @@
         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 __repr__(self):
         return (u'<User username={s.username!r} display_name={s.display_name!r} _id={s._id!r} '
@@ -370,8 +365,10 @@
             session(self).flush(self)
 
     def add_login_detail(self, detail):
-        if detail not in self.previous_login_details:
-            self.previous_login_details.append(detail)
+        try:
+            session(detail).flush(detail)
+        except DuplicateKeyError:
+            session(detail).expunge(detail)
 
     def backfill_login_details(self, auth_provider):
         # ".*" at start of regex and the DOTALL flag is needed only for the test, which uses mim
@@ -998,3 +995,28 @@
     project=RelationProperty('Project'),
     user_id=AlluraUserProperty(),
     user=RelationProperty('User')))
+
+
+class UserLoginDetails(MappedClass):
+    """
+    Store unique entries for users' previous login details.
+
+    Used to help determine if new logins are suspicious or not
+    """
+
+    class __mongometa__:
+        name = 'user_login_details'
+        session = main_explicitflush_orm_session
+        indexes = ['user_id']
+        unique_indexes = [('user_id', 'ip', 'ua'),  # DuplicateKeyError checked in add_login_detail
+                          ]
+
+    _id = FieldProperty(S.ObjectId)
+    user_id = AlluraUserProperty(required=True)
+    ip = FieldProperty(str)
+    ua = FieldProperty(str)
+    extra = FieldProperty({
+        str: S.Anything
+    })
+
+    user = RelationProperty('User')
diff --git a/Allura/allura/model/session.py b/Allura/allura/model/session.py
index 2ab5961..bdd89ac 100644
--- a/Allura/allura/model/session.py
+++ b/Allura/allura/model/session.py
@@ -20,6 +20,7 @@
 from collections import defaultdict
 
 from ming import Session
+from ming.odm.base import ObjectState
 from ming.orm.base import state
 from ming.orm.ormsession import ThreadLocalORMSession, SessionExtension
 from contextlib import contextmanager
@@ -220,6 +221,29 @@
                 raise
 
 
+class ExplicitFlushOnlySessionExtension(SessionExtension):
+    """
+    Used to avoid auto-flushing objects after merely creating them.
+
+    Only save them when we really want to by calling flush(obj) or setting obj.explicit_flush = True
+    """
+
+    def before_flush(self, obj=None):
+        """Before the session is flushed for ``obj``
+
+        If ``obj`` is ``None`` it means all the objects in
+        the UnitOfWork which can be retrieved by iterating
+        over ``ODMSession.uow``
+        """
+        if obj is not None:
+            # this was an explicit flush() call, so let it go through
+            return
+
+        for o in self.session.uow:
+            if not getattr(o, 'explicit_flush', False):
+                state(o).status = ObjectState.clean
+
+
 @contextmanager
 def substitute_extensions(session, extensions=None):
     """
@@ -251,6 +275,10 @@
     doc_session=main_doc_session,
     extensions=[IndexerSessionExtension]
     )
+main_explicitflush_orm_session = ThreadLocalORMSession(
+    doc_session=main_doc_session,
+    extensions=[IndexerSessionExtension, ExplicitFlushOnlySessionExtension]
+)
 project_orm_session = ThreadLocalORMSession(
     doc_session=project_doc_session,
     extensions=[IndexerSessionExtension]
diff --git a/Allura/allura/tests/functional/test_auth.py b/Allura/allura/tests/functional/test_auth.py
index 05bfaa4..3578231 100644
--- a/Allura/allura/tests/functional/test_auth.py
+++ b/Allura/allura/tests/functional/test_auth.py
@@ -150,6 +150,8 @@
         assert_equal(kwargs['subject'], u'Update your %s password' % config['site_name'])
         assert_in('/auth/forgotten_password/', kwargs['text'])
 
+        assert_equal([], M.UserLoginDetails.query.find().all())  # no records created
+
     @patch('allura.tasks.mail_tasks.sendsimplemail')
     def test_login_hibp_compromised_password_trusted_client(self, sendsimplemail):
         self.app.extra_environ = {'disable_auth_magic': 'True'}
diff --git a/Allura/allura/tests/model/test_auth.py b/Allura/allura/tests/model/test_auth.py
index 0f03acb..47dc8b6 100644
--- a/Allura/allura/tests/model/test_auth.py
+++ b/Allura/allura/tests/model/test_auth.py
@@ -436,7 +436,9 @@
     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'},
-    ])
+    details = M.UserLoginDetails.query.find({'user_id': c.user._id}).sort('ua').all()
+    assert_equal(len(details), 2, details)
+    assert_equal(details[0].ip, '127.0.0.1')
+    assert_equal(details[0].ua, 'TestBrowser/56')
+    assert_equal(details[1].ip, '127.0.0.1')
+    assert_equal(details[1].ua, 'TestBrowser/57')
diff --git a/Allura/allura/tests/test_plugin.py b/Allura/allura/tests/test_plugin.py
index c7bd22b..f02f731 100644
--- a/Allura/allura/tests/test_plugin.py
+++ b/Allura/allura/tests/test_plugin.py
@@ -600,38 +600,48 @@
         assert_equal(user.disabled, True)
 
     def test_login_details_from_auditlog(self):
-        assert_equal(self.provider.login_details_from_auditlog(M.AuditLog(
-                        message='')),
+        user = M.User(username='asfdasdf')
+
+        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))
+        detail = self.provider.login_details_from_auditlog(M.AuditLog(message='IP Address: 1.2.3.4\nFoo', user=user))
+        assert_equal(detail.user_id, user._id)
+        assert_equal(detail.ip, '1.2.3.4')
+        assert_equal(detail.ua, None)
+
+        detail = self.provider.login_details_from_auditlog(M.AuditLog(message='Foo\nIP Address: 1.2.3.4\nFoo', user=user))
+        assert_equal(detail.ip, '1.2.3.4')
+        assert_equal(detail.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')),
+                        message='blah blah IP Address: 1.2.3.4\nFoo', user=user)),
                      None)
 
-        assert_equal(self.provider.login_details_from_auditlog(M.AuditLog(
-                        message='User-Agent: Mozilla/Firefox\nFoo')),
-                     dict(ip=None, ua='Mozilla/Firefox'))
+        detail = self.provider.login_details_from_auditlog(M.AuditLog(
+                        message='User-Agent: Mozilla/Firefox\nFoo', user=user))
+        assert_equal(detail.ip, None)
+        assert_equal(detail.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'))
+        detail = self.provider.login_details_from_auditlog(M.AuditLog(
+                        message='IP Address: 1.2.3.4\nUser-Agent: Mozilla/Firefox\nFoo', user=user))
+        assert_equal(detail.ip, '1.2.3.4')
+        assert_equal(detail.ua, 'Mozilla/Firefox')
 
     def test_get_login_detail(self):
-        assert_equal(self.provider.get_login_detail(Request.blank('/')),
-                     dict(ip=None, ua=None))
+        user = M.User(username='foobarbaz')
+        detail = self.provider.get_login_detail(Request.blank('/'), user)
+        assert_equal(detail.user_id, user._id)
+        assert_equal(detail.ip, None)
+        assert_equal(detail.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'))
+        detail = self.provider.get_login_detail(Request.blank('/',
+                                                              headers={'User-Agent': 'mybrowser'},
+                                                              environ={'REMOTE_ADDR': '3.3.3.3'}),
+                                                user)
+        assert_equal(detail.user_id, user._id)
+        assert_equal(detail.ip, '3.3.3.3')
+        assert_equal(detail.ua, 'mybrowser')
 
 
 class TestAuthenticationProvider(object):