Repository: freedomofpress/securedrop

Issue:
/account/reset-2fa-hotp does not catch TypeError
# Bug

## Description

When modifying the HOTP as a journalist (not in the admin interface), TypeError are not caught and a stack trace shows when debug is activated. Otherwise it would bubble to whatever catches server errors.

## Steps to Reproduce

* login as a journalist
* go to /account/account
* click /account/reset-2fa-hotp
* enter three letters
* click save

## Expected Behavior

An error message similar to what happens with /admin/reset-2fa-hotp

## Comments

The /admin/reset-2fa-hotp error handling code should be shared with /account/reset-2fa-hotp



Discussion:
Nice catch, I can reproduce this: 

![screen shot 2018-03-08 at 12 12 03 pm](https://user-images.githubusercontent.com/7832803/37173956-fb929a9c-22c9-11e8-9aea-7a40770332c3.png)

Resolving patch:
diff --git a/securedrop/journalist_app/account.py b/securedrop/journalist_app/account.py
--- a/securedrop/journalist_app/account.py
+++ b/securedrop/journalist_app/account.py
@@ -6,7 +6,7 @@
 
 from db import db
 from journalist_app.utils import (make_password, set_diceware_password,
-                                  validate_user)
+                                  validate_user, validate_hotp_secret)
 
 
 def make_blueprint(config):
@@ -60,6 +60,8 @@ def reset_two_factor_totp():
     def reset_two_factor_hotp():
         otp_secret = request.form.get('otp_secret', None)
         if otp_secret:
+            if not validate_hotp_secret(g.user, otp_secret):
+                return render_template('account_edit_hotp_secret.html')
             g.user.set_hotp_secret(otp_secret)
             db.session.commit()
             return redirect(url_for('account.new_two_factor'))
diff --git a/securedrop/journalist_app/admin.py b/securedrop/journalist_app/admin.py
--- a/securedrop/journalist_app/admin.py
+++ b/securedrop/journalist_app/admin.py
@@ -12,7 +12,7 @@
 from models import Journalist, InvalidUsernameException, PasswordError
 from journalist_app.decorators import admin_required
 from journalist_app.utils import (make_password, commit_account_changes,
-                                  set_diceware_password)
+                                  set_diceware_password, validate_hotp_secret)
 from journalist_app.forms import LogoForm, NewUserForm
 
 
@@ -131,30 +131,10 @@ def reset_two_factor_hotp():
         otp_secret = request.form.get('otp_secret', None)
         if otp_secret:
             user = Journalist.query.get(uid)
-            try:
-                user.set_hotp_secret(otp_secret)
-            except TypeError as e:
-                if "Non-hexadecimal digit found" in str(e):
-                    flash(gettext(
-                        "Invalid secret format: "
-                        "please only submit letters A-F and numbers 0-9."),
-                          "error")
-                elif "Odd-length string" in str(e):
-                    flash(gettext(
-                        "Invalid secret format: "
-                        "odd-length secret. Did you mistype the secret?"),
-                          "error")
-                else:
-                    flash(gettext(
-                        "An unexpected error occurred! "
-                        "Please inform your administrator."), "error")
-                    current_app.logger.error(
-                        "set_hotp_secret '{}' (id {}) failed: {}".format(
-                            otp_secret, uid, e))
+            if not validate_hotp_secret(user, otp_secret):
                 return render_template('admin_edit_hotp_secret.html', uid=uid)
-            else:
-                db.session.commit()
-                return redirect(url_for('admin.new_user_two_factor', uid=uid))
+            db.session.commit()
+            return redirect(url_for('admin.new_user_two_factor', uid=uid))
         else:
             return render_template('admin_edit_hotp_secret.html', uid=uid)
 
diff --git a/securedrop/journalist_app/utils.py b/securedrop/journalist_app/utils.py
--- a/securedrop/journalist_app/utils.py
+++ b/securedrop/journalist_app/utils.py
@@ -102,6 +102,39 @@ def validate_user(username, password, token, error_message=None):
         return None
 
 
+def validate_hotp_secret(user, otp_secret):
+    """
+    Validates and sets the HOTP provided by a user
+    :param user: the change is for this instance of the User object
+    :param otp_secret: the new HOTP secret
+    :return: True if it validates, False if it does not
+    """
+    try:
+        user.set_hotp_secret(otp_secret)
+    except TypeError as e:
+        if "Non-hexadecimal digit found" in str(e):
+            flash(gettext(
+                "Invalid secret format: "
+                "please only submit letters A-F and numbers 0-9."),
+                  "error")
+            return False
+        elif "Odd-length string" in str(e):
+            flash(gettext(
+                "Invalid secret format: "
+                "odd-length secret. Did you mistype the secret?"),
+                  "error")
+            return False
+        else:
+            flash(gettext(
+                "An unexpected error occurred! "
+                "Please inform your administrator."), "error")
+            current_app.logger.error(
+                "set_hotp_secret '{}' (id {}) failed: {}".format(
+                    otp_secret, user.id, e))
+            return False
+    return True
+
+
 def download(zip_basename, submissions):
     """Send client contents of ZIP-file *zip_basename*-<timestamp>.zip
     containing *submissions*. The ZIP-file, being a


Test patch:
diff --git a/securedrop/tests/test_journalist.py b/securedrop/tests/test_journalist.py
--- a/securedrop/tests/test_journalist.py
+++ b/securedrop/tests/test_journalist.py
@@ -544,6 +544,55 @@ def test_user_resets_hotp(self):
         # should redirect to verification page
         self.assertRedirects(resp, url_for('account.new_two_factor'))
 
+    def test_user_resets_user_hotp_format_odd(self):
+        self._login_user()
+        old_hotp = self.user.hotp.secret
+
+        self.client.post(url_for('account.reset_two_factor_hotp'),
+                         data=dict(uid=self.user.id, otp_secret='123'))
+        new_hotp = self.user.hotp.secret
+
+        self.assertEqual(old_hotp, new_hotp)
+        self.assertMessageFlashed(
+            "Invalid secret format: "
+            "odd-length secret. Did you mistype the secret?", "error")
+
+    def test_user_resets_user_hotp_format_non_hexa(self):
+        self._login_user()
+        old_hotp = self.user.hotp.secret
+
+        self.client.post(url_for('account.reset_two_factor_hotp'),
+                         data=dict(uid=self.user.id, otp_secret='ZZ'))
+        new_hotp = self.user.hotp.secret
+
+        self.assertEqual(old_hotp, new_hotp)
+        self.assertMessageFlashed(
+            "Invalid secret format: "
+            "please only submit letters A-F and numbers 0-9.", "error")
+
+    @patch('models.Journalist.set_hotp_secret')
+    @patch('journalist.app.logger.error')
+    def test_user_resets_user_hotp_error(self,
+                                         mocked_error_logger,
+                                         mock_set_hotp_secret):
+        self._login_user()
+        old_hotp = self.user.hotp.secret
+
+        error_message = 'SOMETHING WRONG!'
+        mock_set_hotp_secret.side_effect = TypeError(error_message)
+
+        otp_secret = '1234'
+        self.client.post(url_for('account.reset_two_factor_hotp'),
+                         data=dict(uid=self.user.id, otp_secret=otp_secret))
+        new_hotp = self.user.hotp.secret
+
+        self.assertEqual(old_hotp, new_hotp)
+        self.assertMessageFlashed("An unexpected error occurred! "
+                                  "Please inform your administrator.", "error")
+        mocked_error_logger.assert_called_once_with(
+            "set_hotp_secret '{}' (id {}) failed: {}".format(
+                otp_secret, self.user.id, error_message))
+
     def test_admin_resets_user_totp(self):
         self._login_admin()
         old_totp = self.user.totp