Repository: freedomofpress/securedrop

Issue:
Reset 2FA buttons in the `/admin/edit/<int:user_id>` view are way out of alignment
The reset TOTP button is not justified left and there is no div before the reset TOTP and HOTP buttons as there is in the `/account` view. This would be a good time to DRY out the templates here by making a single template for these two views. There is no security risk in doing so as we rely on the `@login_required` decorator, not choice of template, which is irrelevant if you know the right URL to send the right HTTP request to because you've read the SD source. Obviously, the template would not show the admin-only capabilities to the non-admin users, as a matter of design, not security.


Discussion:

Resolving patch:
diff --git a/securedrop/db.py b/securedrop/db.py
--- a/securedrop/db.py
+++ b/securedrop/db.py
@@ -270,6 +270,9 @@ def _scrypt_hash(self, password, salt, params=None):
     MAX_PASSWORD_LEN = 128
 
     def set_password(self, password):
+        # Don't do anything if user's password hasn't changed.
+        if self.pw_hash and self.valid_password(password):
+            return
         # Enforce a reasonable maximum length for passwords to avoid DoS
         if len(password) > self.MAX_PASSWORD_LEN:
             raise InvalidPasswordLength(password)
diff --git a/securedrop/journalist.py b/securedrop/journalist.py
--- a/securedrop/journalist.py
+++ b/securedrop/journalist.py
@@ -247,40 +247,67 @@ def admin_reset_two_factor_hotp():
         return render_template('admin_edit_hotp_secret.html', uid=uid)
 
 
+class PasswordMismatchError(Exception):
+    pass
+
+
+def edit_account_password(user, password, password_again):
+    if password:
+        if password != password_again:
+            flash("Passwords didn't match!", "error")
+            raise PasswordMismatchError
+        try:
+            user.set_password(password)
+        except InvalidPasswordLength:
+            flash("Password must be less than {} characters!".format(
+                Journalist.MAX_PASSWORD_LEN), 'error')
+            raise
+
+
+def commit_account_changes(user):
+    if db_session.is_modified(user):
+        try:
+            db_session.add(user)
+            db_session.commit()
+        except Exception as e:
+            flash("An unexpected error occurred! Please check the application "
+                  "logs or inform your adminstrator.", "error")
+            app.logger.error("Account changes for '{}' failed: {}".format(user,
+                                                                          e))
+            db_session.rollback()
+        else:
+            flash("Account successfully updated!", "success")
+
+
 @app.route('/admin/edit/<int:user_id>', methods=('GET', 'POST'))
 @admin_required
 def admin_edit_user(user_id):
     user = Journalist.query.get(user_id)
 
     if request.method == 'POST':
-        if request.form['username'] != "":
+        if request.form['username']:
             new_username = request.form['username']
-            if Journalist.query.filter_by(username=new_username).one_or_none():
-                flash("Username {} is already taken".format(new_username),
+            if new_username == user.username:
+                pass
+            elif Journalist.query.filter_by(
+                username=new_username).one_or_none():
+                flash('Username "{}" is already taken!'.format(new_username),
                       "error")
+                return redirect(url_for("admin_edit_user", user_id=user_id))
             else:
                 user.username = new_username
 
-        if request.form['password'] != "":
-            if request.form['password'] != request.form['password_again']:
-                flash("Passwords didn't match", "error")
-                return redirect(url_for("admin_edit_user", user_id=user_id))
-            try:
-                user.set_password(request.form['password'])
-                flash("Password successfully changed for user {} ".format(
-                    user.username), "notification")
-            except InvalidPasswordLength:
-                flash("Your password is too long "
-                      "(maximum length {} characters)".format(
-                      Journalist.MAX_PASSWORD_LEN), "error")
-                return redirect(url_for("admin_edit_user", user_id=user_id))
+        try:
+            edit_account_password(user, request.form['password'],
+                                  request.form['password_again'])
+        except (PasswordMismatchError, InvalidPasswordLength):
+            return redirect(url_for("admin_edit_user", user_id=user_id))
 
         user.is_admin = bool(request.form.get('is_admin'))
 
-        db_session.add(user)
-        db_session.commit()
+        commit_account_changes(user)
 
-    return render_template("admin_edit_user.html", user=user)
+    return render_template("edit_account.html", user=user)
 
 
 @app.route('/admin/delete/<int:user_id>', methods=('POST',))
@@ -306,31 +333,14 @@ def edit_account():
     user = g.user
 
     if request.method == 'POST':
-        if request.form['password'] != "":
-            if request.form['password'] != request.form['password_again']:
-                flash("Passwords didn't match", "error")
-                return redirect(url_for("edit_account"))
-            try:
-                user.set_password(request.form['password'])
-            except InvalidPasswordLength:
-                flash("Your password is too long "
-                      "(maximum length {} characters)".format(
-                      Journalist.MAX_PASSWORD_LEN), "error")
-                return redirect(url_for("edit_account"))
-
         try:
-            db_session.add(user)
-            db_session.commit()
-            flash(
-                "Password successfully changed!",
-                "notification")
-        except Exception as e:
-            flash(
-                "An unknown error occurred, please inform your administrator",
-                "error")
-            app.logger.error("Password change for '{}' failed: {}".format(
-                user, e))
-            db_session.rollback()
+            edit_account_password(user, request.form['password'],
+                                  request.form['password_again'])
+        except (PasswordMismatchError, InvalidPasswordLength):
+            return redirect(url_for('edit_account'))
+
+        commit_account_changes(user)
+
     return render_template('edit_account.html')
 
 


Test patch:
diff --git a/securedrop/tests/functional/journalist_navigation_steps.py b/securedrop/tests/functional/journalist_navigation_steps.py
--- a/securedrop/tests/functional/journalist_navigation_steps.py
+++ b/securedrop/tests/functional/journalist_navigation_steps.py
@@ -75,10 +75,6 @@ def _admin_visits_admin_interface(self):
         h1s = self.driver.find_elements_by_tag_name('h1')
         self.assertIn("Admin Interface", [el.text for el in h1s])
 
-        users_table_rows = self.driver.find_elements_by_css_selector(
-            'table#users tr.user')
-        self.assertEquals(len(users_table_rows), 1)
-
     def _add_user(self, username, password, is_admin=False):
         username_field = self.driver.find_element_by_css_selector(
             'input[name="username"]')
@@ -176,12 +172,76 @@ def _new_user_can_log_in(self):
                           self.driver.find_element_by_link_text,
                           'Admin')
 
+    def _edit_account(self):
+        edit_account_link = self.driver.find_element_by_link_text(
+            'Edit Account')
+        edit_account_link.click()
+
+        # The header says "Edit your account"
+        h1s = self.driver.find_elements_by_tag_name('h1')[0]
+        self.assertEqual('Edit your account', h1s.text)
+        # There's no link back to the admin interface.
+        with self.assertRaises(NoSuchElementException):
+            self.driver.find_element_by_partial_link_text('Back to admin interface')
+        # There's no field to change your username.
+        with self.assertRaises(NoSuchElementException):
+            self.driver.find_element_by_css_selector('#username')
+        # There's no checkbox to change the administrator status of your
+        # account.
+        with self.assertRaises(NoSuchElementException):
+            username_field = self.driver.find_element_by_css_selector('#is_admin')
+        # 2FA reset buttons at the bottom point to the user URLs for reset.
+        totp_reset_button = self.driver.find_elements_by_css_selector(
+            '#reset-two-factor-totp')[0]
+        self.assertRegexpMatches(totp_reset_button.get_attribute('action'),
+                                 '/account/reset-2fa-totp')
+        hotp_reset_button = self.driver.find_elements_by_css_selector(
+            '#reset-two-factor-hotp')[0]
+        self.assertRegexpMatches(hotp_reset_button.get_attribute('action'),
+                                 '/account/reset-2fa-hotp')
+
     def _edit_user(self, username):
+        user = Journalist.query.filter_by(username=username).one()
+
         new_user_edit_links = filter(
             lambda el: el.get_attribute('data-username') == username,
             self.driver.find_elements_by_tag_name('a'))
         self.assertEquals(len(new_user_edit_links), 1)
         new_user_edit_links[0].click()
+        # The header says "Edit user "username"".
+        h1s = self.driver.find_elements_by_tag_name('h1')[0]
+        self.assertEqual('Edit user "{}"'.format(username), h1s.text)
+        # There's a convenient link back to the admin interface.
+        admin_interface_link = self.driver.find_element_by_partial_link_text(
+            'Back to admin interface')
+        self.assertRegexpMatches(admin_interface_link.get_attribute('href'),
+                                 '/admin$')
+        # There's a field to change the user's username and it's already filled
+        # out with the user's username.
+        username_field = self.driver.find_element_by_css_selector('#username')
+        self.assertEqual(username_field.get_attribute('placeholder'), username)
+        # There's a checkbox to change the administrator status of the user and
+        # it's already checked appropriately to reflect the current status of
+        # our user.
+        username_field = self.driver.find_element_by_css_selector('#is_admin')
+        self.assertEqual(bool(username_field.get_attribute('checked')),
+                         user.is_admin)
+        # 2FA reset buttons at the bottom point to the admin URLs for
+        # resettting 2FA and include the correct user id in the hidden uid.
+        totp_reset_button = self.driver.find_elements_by_css_selector(
+            '#reset-two-factor-totp')[0]
+        self.assertRegexpMatches(totp_reset_button.get_attribute('action'),
+                                 '/admin/reset-2fa-totp')
+        totp_reset_uid = totp_reset_button.find_element_by_name('uid')
+        self.assertEqual(int(totp_reset_uid.get_attribute('value')), user.id)
+        self.assertFalse(totp_reset_uid.is_displayed())
+        hotp_reset_button = self.driver.find_elements_by_css_selector(
+            '#reset-two-factor-hotp')[0]
+        self.assertRegexpMatches(hotp_reset_button.get_attribute('action'),
+                                 '/admin/reset-2fa-hotp')
+        hotp_reset_uid = hotp_reset_button.find_element_by_name('uid')
+        self.assertEqual(int(hotp_reset_uid.get_attribute('value')), user.id)
+        self.assertFalse(hotp_reset_uid.is_displayed())
 
     def _admin_can_edit_new_user(self):
         # Log the new user out
@@ -250,7 +310,7 @@ def _admin_can_edit_new_user(self):
             'input[name="password_again"]')
         password_again_field.send_keys(new_password)
         update_user_btn = self.driver.find_element_by_css_selector(
-            'button#update-user')
+            'button#update')
         update_user_btn.click()
 
         # Wait until page refreshes to avoid causing a broken pipe error (#623)
diff --git a/securedrop/tests/functional/make_account_changes.py b/securedrop/tests/functional/make_account_changes.py
new file mode 100644
--- /dev/null
+++ b/securedrop/tests/functional/make_account_changes.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+from unittest import TestCase
+
+from functional_test import FunctionalTest
+from journalist_navigation_steps import JournalistNavigationSteps
+
+class MakeAccountChanges(FunctionalTest, JournalistNavigationSteps, TestCase):
+    def test_admin_edit_account_html_template_rendering(self):
+        """The edit_account.html template is used both when an admin is editing
+        a user's account, and when a user is editing their own account. While
+        there is no security risk in doing so, we do want to ensure the UX is
+        as expected: that only the elements that belong in a particular view
+        are exposed there."""
+        self._admin_logs_in()
+        self._admin_visits_admin_interface()
+        # Admin view of admin user
+        self._edit_user('admin')
+        self._admin_visits_admin_interface()
+        self._admin_adds_a_user()
+        # Admin view of non-admin user
+        self._edit_user('dellsberg')
+        # User view of self
+        self._edit_account()
+        self._logout()
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
@@ -181,14 +181,14 @@ def test_admin_edits_user_password_success_response(self):
             data=dict(username=self.user.username, is_admin=False,
                       password='valid', password_again='valid'))
 
-        self.assertIn('Password successfully changed', resp.data)
+        self.assertMessageFlashed("Account successfully updated!", 'success')
 
     def test_user_edits_password_success_reponse(self):
         self._login_user()
         resp = self.client.post(url_for('edit_account'),
                                 data=dict(password='valid',
                                           password_again='valid'))
-        self.assertIn("Password successfully changed", resp.data)
+        self.assertMessageFlashed("Account successfully updated!", 'success')
 
     def test_admin_edits_user_password_mismatch_warning(self):
         self._login_admin()
@@ -199,7 +199,7 @@ def test_admin_edits_user_password_mismatch_warning(self):
                       password='not', password_again='thesame'),
             follow_redirects=True)
 
-        self.assertIn(escape("Passwords didn't match"), resp.data)
+        self.assertMessageFlashed("Passwords didn't match!", "error")
 
     def test_user_edits_password_mismatch_redirect(self):
         self._login_user()
@@ -237,7 +237,9 @@ def test_admin_edits_user_password_too_long_warning(self):
                       password_again=overly_long_password),
             follow_redirects=True)
 
-        self.assertIn('Your password is too long', resp.data)
+        self.assertMessageFlashed('Password must be less than {} '
+                                  'characters!'.format(
+                                      Journalist.MAX_PASSWORD_LEN), 'error')
 
     def test_user_edits_password_too_long_warning(self):
         self._login_user()
@@ -248,7 +250,9 @@ def test_user_edits_password_too_long_warning(self):
                                           password_again=overly_long_password),
                                 follow_redirects=True)
 
-        self.assertIn('Your password is too long', resp.data)
+        self.assertMessageFlashed('Password must be less than {} '
+                                  'characters!'.format(
+                                      Journalist.MAX_PASSWORD_LEN), 'error')
 
     def test_admin_add_user_password_too_long_warning(self):
         self._login_admin()
@@ -272,8 +276,8 @@ def test_admin_edits_user_invalid_username(self):
             data=dict(username=new_username, is_admin=False,
                       password='', password_again=''))
 
-        self.assertIn('Username {} is already taken'.format(new_username),
-                      resp.data)
+        self.assertMessageFlashed('Username "{}" is already taken!'.format(
+            new_username), 'error')
 
     def test_admin_resets_user_hotp(self):
         self._login_admin()
@@ -436,14 +440,16 @@ def test_too_long_user_password_change(self):
             password_again=overly_long_password),
             follow_redirects=True)
 
-        self.assertIn('Your password is too long', res.data)
+        self.assertMessageFlashed('Password must be less than {} '
+                                  'characters!'.format(
+                                      Journalist.MAX_PASSWORD_LEN), 'error')
 
     def test_valid_user_password_change(self):
         self._login_user()
         res = self.client.post(url_for('edit_account'), data=dict(
             password='valid',
             password_again='valid'))
-        self.assertIn("Password successfully changed", res.data)
+        self.assertMessageFlashed("Account successfully updated!", 'success')
 
     def test_regenerate_totp(self):
         self._login_user()