[#8279] option to require password resets if listed on HIBP
diff --git a/Allura/allura/controllers/auth.py b/Allura/allura/controllers/auth.py
index 9273147..299664d 100644
--- a/Allura/allura/controllers/auth.py
+++ b/Allura/allura/controllers/auth.py
@@ -211,33 +211,20 @@
         allow_non_primary_email_reset = asbool(config.get('auth.allow_non_primary_email_password_reset', True))
 
         if not re.match(r"[^@]+@[^@]+\.[^@]+", email):
-            flash('Enter email in correct format!','error')
+            flash('Enter email in correct format!', 'error')
             redirect('/auth/forgotten_password')
 
         if not allow_non_primary_email_reset:
             message = 'If the given email address is on record, '\
                       'a password reset email has been sent to the account\'s primary email address.'
             email_record = M.EmailAddress.get(email=provider.get_primary_email_address(user_record=user_record),
-                                                    confirmed=True)
+                                              confirmed=True)
         else:
             message = 'A password reset email has been sent, if the given email address is on record in our system.'
             email_record = M.EmailAddress.get(email=email, confirmed=True)
 
         if user_record and email_record and email_record.confirmed:
-            hash = h.nonce(42)
-            user_record.set_tool_data('AuthPasswordReset',
-                                      hash=hash,
-                                      hash_expiry=datetime.utcnow() +
-                                      timedelta(seconds=int(config.get('auth.recovery_hash_expiry_period', 600))))
-
-            log.info('Sending password recovery link to %s', email_record.email)
-            subject = '%s Password recovery' % config['site_name']
-            text = g.jinja2_env.get_template('allura:templates/mail/forgot_password.txt').render(dict(
-                user=user_record,
-                config=config,
-                hash=hash,
-            ))
-            send_system_mail_to_user(email_record.email, subject, text)
+            user_record.send_password_reset_email(email_record.email)
         h.auditlog_user('Password recovery link sent to: %s', email, user=user_record)
         flash(message)
         redirect('/')
@@ -522,9 +509,10 @@
         session.pop('pwd-expired', None)
         session['username'] = session.get('expired-username')
         session.pop('expired-username', None)
+        expired_reason = session.pop('expired-reason', None)
 
         session.save()
-        h.auditlog_user('Password reset (via expiration process)')
+        h.auditlog_user('Password reset ({})'.format(expired_reason))
         if return_to and return_to != request.url:
             redirect(return_to)
         else:
diff --git a/Allura/allura/lib/plugin.py b/Allura/allura/lib/plugin.py
index 9156f79..c52453f 100644
--- a/Allura/allura/lib/plugin.py
+++ b/Allura/allura/lib/plugin.py
@@ -41,7 +41,7 @@
     ldap = modlist = None
 import pkg_resources
 import tg
-from tg import config, request, redirect, response
+from tg import config, request, redirect, response, flash
 from tg import tmpl_context as c, app_globals as g
 from webob import exc, Request
 from paste.deploy.converters import asbool, asint
@@ -187,10 +187,20 @@
         else:
             self.session.pop('multifactor-username', None)
 
+        login_details = self.get_login_detail(self.request)
+
+        expire_reason = None
         if self.is_password_expired(user):
+            h.auditlog_user('Successful login; Password expired', user=user)
+            expire_reason = 'via expiration process'
+        if not expire_reason and 'password' in self.request.params:
+            # password not present with multifactor token; or if login directly after registering is enabled
+            expire_reason = self.login_check_password_change_needed(user, self.request.params['password'],
+                                                                    login_details)
+        if expire_reason:
             self.session['pwd-expired'] = True
             self.session['expired-username'] = user.username
-            h.auditlog_user('Successful login; Password expired', user=user)
+            self.session['expired-reason'] = expire_reason
         else:
             self.session['username'] = user.username
             h.auditlog_user('Successful login', user=user)
@@ -204,7 +214,7 @@
             self.session['login_expires'] = True
         self.session.save()
         g.statsUpdater.addUserLogin(user)
-        user.add_login_detail(self.get_login_detail(self.request))
+        user.add_login_detail(login_details)
         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
@@ -213,6 +223,36 @@
                             secure=False, httponly=True)
         return user
 
+    def login_check_password_change_needed(self, user, password, login_details):
+        if not self.hibp_password_check_enabled() \
+                or not asbool(tg.config.get('auth.hibp_failure_force_pwd_change', False)):
+            return
+
+        try:
+            security.HIBPClient.check_breached_password(password)
+        except security.HIBPClientError as ex:
+            log.error("Error invoking HIBP API", exc_info=ex)
+        except security.HIBPCompromisedCredentials:
+            trusted = False
+            try:
+                trusted = self.trusted_login_source(user, login_details)
+            except Exception:
+                log.exception('Error checking if login is trusted: %s %s', user.username, login_details)
+
+            if trusted:
+                # current user must change password
+                h.auditlog_user(u'Successful login with password in HIBP breach database, '
+                                u'from trusted source (reason: {})'.format(trusted), user=user)
+                return 'hibp'  # reason
+            else:
+                # current user may not continue, must reset password via email
+                h.auditlog_user('Attempted login from untrusted location with password in HIBP breach database',
+                                user=user)
+                user.send_password_reset_email(subject_tmpl=u'Update your {site_name} password')
+                raise exc.HTTPBadRequest('To ensure account security, you must reset your password via email.'
+                                         '\n'
+                                         'Please check your email to continue.')
+
     def logout(self):
         self.session.invalidate()
         self.session.save()
@@ -416,6 +456,17 @@
             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:
+            if prev_login['ip'] == login_details['ip']:
+                return 'exact ip'
+            if asbool(tg.config.get('auth.trust_ip_3_octets_match', False)) and \
+                    utils.close_ipv4_addrs(prev_login['ip'], login_details['ip']):
+                return 'close ip'
+
+        return False
+
 
 class LocalAuthenticationProvider(AuthenticationProvider):
 
diff --git a/Allura/allura/lib/utils.py b/Allura/allura/lib/utils.py
index 450bade..91faac1 100644
--- a/Allura/allura/lib/utils.py
+++ b/Allura/allura/lib/utils.py
@@ -846,3 +846,7 @@
     then encoded as per normal.
     """
     return urllib.urlencode([i for i in generate_smart_str(params)])
+
+
+def close_ipv4_addrs(ip1, ip2):
+    return ip1.split('.')[0:3] == ip2.split('.')[0:3]
diff --git a/Allura/allura/model/auth.py b/Allura/allura/model/auth.py
index 44ed66d..2a15a45 100644
--- a/Allura/allura/model/auth.py
+++ b/Allura/allura/model/auth.py
@@ -385,6 +385,24 @@
             if login_detail:
                 self.add_login_detail(login_detail)
 
+    def send_password_reset_email(self, email_address=None, subject_tmpl=u'{site_name} Password recovery'):
+        if email_address is None:
+            email_address = self.get_pref('email_address')
+        hash = h.nonce(42)
+        self.set_tool_data('AuthPasswordReset',
+                           hash=hash,
+                           hash_expiry=datetime.utcnow() +
+                                       timedelta(seconds=int(config.get('auth.recovery_hash_expiry_period', 600))))
+
+        log.info('Sending password recovery link to %s', email_address)
+        subject = subject_tmpl.format(site_name=config['site_name'])
+        text = g.jinja2_env.get_template('allura:templates/mail/forgot_password.txt').render(dict(
+            user=self,
+            config=config,
+            hash=hash,
+        ))
+        allura.tasks.mail_tasks.send_system_mail_to_user(email_address, subject, text)
+
     def can_send_user_message(self):
         """Return true if User is permitted to send a mesage to another user.
 
diff --git a/Allura/allura/nf/allura/css/site_style.css b/Allura/allura/nf/allura/css/site_style.css
index 69d0b5f..040336d 100644
--- a/Allura/allura/nf/allura/css/site_style.css
+++ b/Allura/allura/nf/allura/css/site_style.css
@@ -682,7 +682,7 @@
   -o-box-shadow: none;
   box-shadow: none;
   display: block;
-  cursor: default;
+  cursor: auto;
   margin-bottom: 1em;
   padding: 0;
 }
@@ -3109,8 +3109,9 @@
   font-style: italic;
 }
 
-div.message.scm-learn-basics, div.message.scm-ssh-key, div.message.scm-empty-repo {
-  cursor: default;
+/* .message default styles are hidden and controlled by jquery.notify.js.  These are to be displayed as regular blocks */
+div.message.show-message, div.message.scm-learn-basics, div.message.scm-ssh-key, div.message.scm-empty-repo {
+  cursor: auto;
   display: block;
   width: auto;
   margin: 0 1em 1em;
diff --git a/Allura/allura/templates/mail/forgot_password.txt b/Allura/allura/templates/mail/forgot_password.txt
index 5f5f71a..0e23312 100644
--- a/Allura/allura/templates/mail/forgot_password.txt
+++ b/Allura/allura/templates/mail/forgot_password.txt
@@ -19,6 +19,6 @@
 
 Your username is {{ user.username }}
 
-To reset your password on {{ config['site_name'] }}, please visit the following URL:
+To update your password on {{ config['site_name'] }}, please visit the following URL:
 
 {{ config['base_url'] }}/auth/forgotten_password/{{hash}}
diff --git a/Allura/allura/templates/pwd_expired.html b/Allura/allura/templates/pwd_expired.html
index 2af886d..476bc89 100644
--- a/Allura/allura/templates/pwd_expired.html
+++ b/Allura/allura/templates/pwd_expired.html
@@ -20,13 +20,30 @@
 {% set hide_left_bar = True %}
 {% extends g.theme.master %}
 
-{% block title %}{{c.user.username}} / Update expired password{% endblock %}
+{% block title %}{{c.user.username}} / Update password{% endblock %}
 
-{% block header %}Update expired password for {{c.user.username}}{% endblock %}
+{% block header %}Update password for {{c.user.username}}{% endblock %}
 
 {% block content %}
-  <div class='grid-23'>
-    <p>Your {{config['site_name']}} password has expired - You must now choose a new password before logging into the site</p>
+  <div class='grid-15'>
+    <p>Your {{config['site_name']}} password
+        {% if session.get('expired-reason') == 'hibp' %}
+            must be updated to be more secure.
+        {% else %}
+            has expired.
+        {% endif %}
+        You must now choose a new password before logging into the site.</p>
+  </div>
+  <div class="grid-23">
     {{ c.form.display(action='/auth/pwd_expired_change', value=dict(return_to=return_to)) }}
   </div>
+  {% if h.asbool(config.get('auth.multifactor.totp')) %}
+  <div class='grid-8' style="position: absolute">
+    <div class="message info show-message">
+        Did you know?  You can also enable multifactor authentication on your account.
+        <br><br>
+        After changing your password, go to your account settings to set it up.
+    </div>
+  </div>
+  {% endif %}
 {% endblock content %}
diff --git a/Allura/allura/templates/widgets/forge_form.html b/Allura/allura/templates/widgets/forge_form.html
index c62404e..1ff6702 100644
--- a/Allura/allura/templates/widgets/forge_form.html
+++ b/Allura/allura/templates/widgets/forge_form.html
@@ -26,13 +26,13 @@
     {% set extra_width = 4 %}
   {% endif %}
   {% if errors and not errors.iteritems and show_errors %}
-  <div class="grid-{{19 + extra_width}}"><span {{widget.j2_attrs({'class':error_class})}}>{{errors}}</span></div>
+  <div class="grid-{{19 + extra_width}}"><span {{widget.j2_attrs({'class':error_class})}}>{{errors|nl2br}}</span></div>
   {% endif %}
   {% for field in widget.fields %}
     {% set ctx=widget.context_for(field) %}
     {% if field.field_type != 'hidden' %}
       {% if ctx.errors and field.show_errors -%}
-      <div class="grid-{{19 + extra_width}}"><span {{widget.j2_attrs({'class':error_class})}}>{{ctx.errors}}</span></div>
+      <div class="grid-{{19 + extra_width}}"><span {{widget.j2_attrs({'class':error_class})}}>{{ctx.errors|nl2br}}</span></div>
       {%- endif %}
       {% if field.show_label and field.label %}
       <label for="{{ctx.id}}" class="grid-4">{{field.label}}:</label>
diff --git a/Allura/allura/templates_responsive/widgets/forge_form.html b/Allura/allura/templates_responsive/widgets/forge_form.html
index 29ef744..43b72e7 100644
--- a/Allura/allura/templates_responsive/widgets/forge_form.html
+++ b/Allura/allura/templates_responsive/widgets/forge_form.html
@@ -30,13 +30,13 @@
       {% if target %}target="{{target}}"{% endif %}
       action="{{action}}">
   {% if errors and not errors.iteritems and show_errors %}
-  <div class=""><span {{widget.j2_attrs({'class':error_class})}}>{{errors}}</span></div>
+  <div class=""><span {{widget.j2_attrs({'class':error_class})}}>{{errors|nl2br}}</span></div>
   {% endif %}
   {% for field in widget.fields %}
     {% set ctx=widget.context_for(field) %}
     {% if field.field_type != 'hidden' %}
       {% if ctx.errors and field.show_errors -%}
-      <div class="column small-12 large-6"><span {{widget.j2_attrs({'class':error_class})}}>{{ctx.errors}}</span></div>
+      <div class="column small-12 large-6"><span {{widget.j2_attrs({'class':error_class})}}>{{ctx.errors|nl2br}}</span></div>
       {%- endif %}
       {% if field.show_label and field.label %}
       <label for="{{ctx.id}}" class="column small-12 large-3">{{field.label}}:</label>
diff --git a/Allura/allura/tests/functional/test_auth.py b/Allura/allura/tests/functional/test_auth.py
index a0419ff..09af206 100644
--- a/Allura/allura/tests/functional/test_auth.py
+++ b/Allura/allura/tests/functional/test_auth.py
@@ -128,6 +128,63 @@
         assert_equal(wf['status'], 'error')
         assert_equal(wf['message'], 'Spambot protection engaged')
 
+    @patch('allura.lib.plugin.AuthenticationProvider.hibp_password_check_enabled', Mock(return_value=True))
+    @patch('allura.tasks.mail_tasks.sendsimplemail')
+    def test_login_hibp_compromised_password_untrusted_client(self, sendsimplemail):
+        # first & only login by this user, so won't have any trusted previous logins
+        self.app.extra_environ = {'disable_auth_magic': 'True'}
+        r = self.app.get('/auth/')
+        f = r.forms[0]
+        encoded = self.app.antispam_field_names(f)
+        f[encoded['username']] = 'test-user'
+        f[encoded['password']] = 'foo'
+
+        with audits('Attempted login from untrusted location with password in HIBP breach database', user=True):
+            r = f.submit(status=200)
+
+        r.mustcontain('reset your password via email.')
+        r.mustcontain('reset your password via email.<br>\nPlease check your email')
+
+        args, kwargs = sendsimplemail.post.call_args
+        assert_equal(sendsimplemail.post.call_count, 1)
+        assert_equal(kwargs['subject'], u'Update your %s password' % config['site_name'])
+        assert_in('/auth/forgotten_password/', kwargs['text'])
+
+    @patch('allura.tasks.mail_tasks.sendsimplemail')
+    def test_login_hibp_compromised_password_trusted_client(self, sendsimplemail):
+        self.app.extra_environ = {'disable_auth_magic': 'True'}
+
+        # regular login first, so IP address will be recorded and then trusted
+        r = self.app.get('/auth/')
+        f = r.forms[0]
+        encoded = self.app.antispam_field_names(f)
+        f[encoded['username']] = 'test-user'
+        f[encoded['password']] = 'foo'
+        with audits('Successful login', user=True):
+            f.submit(status=302)
+        self.app.get('/auth/logout')
+
+        # this login will get caught by HIBP check, but trusted due to IP address being same
+        with patch('allura.lib.plugin.AuthenticationProvider.hibp_password_check_enabled', Mock(return_value=True)):
+            r = self.app.get('/auth/')
+            f = r.forms[0]
+            encoded = self.app.antispam_field_names(f)
+            f[encoded['username']] = 'test-user'
+            f[encoded['password']] = 'foo'
+
+            with audits(r'Successful login with password in HIBP breach database, from trusted source '
+                        r'\(reason: exact ip\)', user=True):
+                r = f.submit(status=302)
+
+            assert r.session.get('pwd-expired')
+            assert_equal(r.session.get('expired-reason'), 'hibp')
+            assert_equal(r.location, 'http://localhost/auth/pwd_expired')
+
+            r = r.follow()
+            r.mustcontain('must be updated to be more secure')
+
+            # changing password covered in TestPasswordExpire
+
     def test_logout(self):
         self.app.extra_environ = {'disable_auth_magic': 'True'}
         nav_pattern = ('nav', {'class': 'nav-main'})
@@ -1488,7 +1545,7 @@
         # confirm email sent
         text = '''Your username is test-admin
 
-To reset your password on %s, please visit the following URL:
+To update your password on %s, please visit the following URL:
 
 %s/auth/forgotten_password/%s''' % (config['site_name'], config['base_url'], hash)
         sendmail.post.assert_called_once_with(
@@ -1578,7 +1635,7 @@
     @patch('allura.lib.plugin.AuthenticationProvider.hibp_password_check_enabled', Mock(return_value=True))
     @patch('allura.tasks.mail_tasks.sendsimplemail')
     @patch('allura.lib.helpers.gen_message_id')
-    def test_hibp_check(self, gen_message_id, sendmail):
+    def test_pwd_reset_hibp_check(self, gen_message_id, sendmail):
         self.app.get('/').follow()  # establish session
         user = M.User.query.get(username='test-admin')
         email = M.EmailAddress.find({'claimed_by_user_id': user._id}).first()
@@ -2533,6 +2590,40 @@
         # confirm code used up
         assert_not_in(recovery_code, RecoveryCodeService().get().get_codes(user))
 
+    @patch('allura.lib.plugin.AuthenticationProvider.hibp_password_check_enabled', Mock(return_value=True))
+    def test_login_totp_with_hibp(self):
+        # this is essentially the same as regular TOTP test, just making sure that HIBP doesn't get in the way
+        # or cause any problems.  It shouldn't even run since a password isn't present when the final login happens
+
+        self._init_totp()
+
+        # so test-admin isn't automatically logged in for all requests
+        self.app.extra_environ = {'disable_auth_magic': 'True'}
+
+        # regular login
+        r = self.app.get('/auth/?return_to=/p/foo')
+        encoded = self.app.antispam_field_names(r.form)
+        r.form[encoded['username']] = 'test-admin'
+        r.form[encoded['password']] = 'foo'
+        with audits('Multifactor login - password ok, code not entered yet', user=True):
+            r = r.form.submit()
+
+        # check results
+        assert r.location.endswith('/auth/multifactor?return_to=%2Fp%2Ffoo'), r
+        r = r.follow()
+        assert not r.session.get('username')
+
+        # use a valid code
+        totp = TotpService().Totp(self.sample_key)
+        code = totp.generate(time_time())
+        r.form['code'] = code
+        with audits('Successful login', user=True):
+            r = r.form.submit()
+
+        # confirm login and final page
+        assert_equal(r.session['username'], 'test-admin')
+        assert r.location.endswith('/p/foo'), r
+
     def test_view_key(self):
         self._init_totp()
 
diff --git a/Allura/allura/tests/functional/test_site_admin.py b/Allura/allura/tests/functional/test_site_admin.py
index cb61e58..0831926 100644
--- a/Allura/allura/tests/functional/test_site_admin.py
+++ b/Allura/allura/tests/functional/test_site_admin.py
@@ -705,7 +705,7 @@
         hash = user.get_tool_data('AuthPasswordReset', 'hash')
         text = '''Your username is test-user
 
-To reset your password on %s, please visit the following URL:
+To update your password on %s, please visit the following URL:
 
 %s/auth/forgotten_password/%s''' % (config['site_name'], config['base_url'], hash)
         sendmail.post.assert_called_once_with(
diff --git a/Allura/allura/tests/test_utils.py b/Allura/allura/tests/test_utils.py
index 5f642a7..199b4e5 100644
--- a/Allura/allura/tests/test_utils.py
+++ b/Allura/allura/tests/test_utils.py
@@ -398,3 +398,9 @@
         assert not utils.is_nofollow_url('http://foo.com/')
         assert not utils.is_nofollow_url('http://bar.io/')
         assert utils.is_nofollow_url('http://bar.iot/')
+
+
+def test_close_ipv4_addrs():
+    assert utils.close_ipv4_addrs('1.2.3.4', '1.2.3.4')
+    assert utils.close_ipv4_addrs('1.2.3.4', '1.2.3.255')
+    assert not utils.close_ipv4_addrs('1.2.3.4', '1.2.4.4')
\ No newline at end of file
diff --git a/Allura/development.ini b/Allura/development.ini
index 640ab73..ed2cf50 100644
--- a/Allura/development.ini
+++ b/Allura/development.ini
@@ -227,6 +227,13 @@
 ; that are known to be compromised
 auth.hibp_password_check = false
 
+; if auth.hibp_password_check is true and this is also set to true, then a password reset will be forced
+; either via the current session if the login ip is "trusted", or via a password reset email if not trusted
+auth.hibp_failure_force_pwd_change = true
+; if auth.hibp_password_check and auth.hibp_failure_force_pwd_change are true, then this is used to determine if a
+; login can be trusted to reset their own ip address.  If set to false, then only the exact same IP can reset a
+; HIBP-listed password without going through email
+auth.auth.trust_ip_3_octets_match = true
 
 user_prefs_storage.method = local
 ; user_prefs_storage.method = ldap