Repository: freedomofpress/securedrop

Issue:
Allow journalists to reset their own login credentials
Currently, it is possible for **admin** users to reset any other user's login credentials (password, 2fa). This is primarily useful for credential recovery, e.g. a journalist forgets their password, loses their two-factor device, or gets a new one.

I still advocate for keeping this feature in place as a disaster recovery mechanism. If a journalist cannot log in at all (lost password or 2fa device), then they will need an admin's assistance to regain access.

However, there is an additional use case where the journalist has not lost access, but wishes to switch to using a different two-factor device. One common example is where the journalist uses a TOTP app (like Google Authenticator) on their smartphone, and gets a new smart phone. For security reasons, these apps usually do not allow the two-factor secrets to be extracted or transferred without taking exceptional and potentially dangerous steps (e.g. rooting or jailbreaking the device's OS).

Another example would be a journalist who has been using a two-factor app on their phone, but wishes to switch to a two-factor hardware token (e.g. a Yubikey).

These use cases are all possible now with an admin's assistance, but this is cumbersome and may be difficult or impossible to arrange if the journalist and the admin are not physically co-located.

We should add support for journalists to reset or update their own credentials, while maintaining admin's ability to reset all user's credentials in case they lose access to their account.

Related to #1091.



Discussion:

Resolving patch:
diff --git a/securedrop/journalist.py b/securedrop/journalist.py
--- a/securedrop/journalist.py
+++ b/securedrop/journalist.py
@@ -219,7 +219,6 @@ def admin_new_user_two_factor():
 
     return render_template("admin_new_user_two_factor.html", user=user)
 
-
 @app.route('/admin/reset-2fa-totp', methods=['POST'])
 @admin_required
 def admin_reset_two_factor_totp():
@@ -271,6 +270,11 @@ def admin_edit_user(user_id):
         try:
             db_session.add(user)
             db_session.commit()
+            flash("Password successfully changed for user {} ".format(
+                  user.username),
+                  "notification"
+            )
+
         except Exception as e:
             db_session.rollback()
             if "username is not unique" in str(e):
@@ -292,6 +296,81 @@ def admin_delete_user(user_id):
     return redirect(url_for('admin_index'))
 
 
+@app.route('/account', methods=('GET', 'POST'))
+@login_required
+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()
+    return render_template('edit_account.html')
+
+
+@app.route('/account/2fa', methods=('GET', 'POST'))
+@login_required
+def account_new_two_factor():
+    user = g.user
+
+    if request.method == 'POST':
+        token = request.form['token']
+        if user.verify_token(token):
+            flash(
+                "Two factor token successfully verified!",
+                "notification")
+            return redirect(url_for('edit_account'))
+        else:
+            flash("Two factor token failed to verify", "error")
+
+    return render_template('account_new_two_factor.html', user=user)
+
+
+@app.route('/account/reset-2fa-totp', methods=['POST'])
+@login_required
+def account_reset_two_factor_totp():
+    user = g.user
+    user.is_totp = True
+    user.regenerate_totp_shared_secret()
+    db_session.commit()
+    return redirect(url_for('account_new_two_factor'))
+
+
+@app.route('/account/reset-2fa-hotp', methods=['POST'])
+@login_required
+def account_reset_two_factor_hotp():
+    user = g.user
+    otp_secret = request.form.get('otp_secret', None)
+    if otp_secret:
+        user.set_hotp_secret(otp_secret)
+        db_session.commit()
+        return redirect(url_for('account_new_two_factor'))
+    else:
+        return render_template('account_edit_hotp_secret.html')
+
+
 def make_star_true(sid):
     source = get_source(sid)
     if source.star:


Test patch:
diff --git a/securedrop/tests/test_unit_integration.py b/securedrop/tests/test_unit_integration.py
--- a/securedrop/tests/test_unit_integration.py
+++ b/securedrop/tests/test_unit_integration.py
@@ -541,6 +541,71 @@ def test_filenames_delete(self):
             soup.select('ul#submissions li a .filename')[2].contents[0])
         self.assertTrue(re.match(submission_filename_re.format(4), filename))
 
+    def test_user_change_password(self):
+        """Test that a journalist can successfully login after changing their password"""
+          
+        # change password
+        self.journalist_app.post('/account', data=dict(
+            password='newpass',
+            password_again='newpass'
+        ))
+
+        # logout
+        common.logout(self.journalist_app)
+
+        # login with new credentials should redirect to index page
+        rv = self.journalist_app.post('/login', data=dict(
+            username=self.user.username,
+            password='newpass',
+            token=self.user.totp.now(),
+            follow_redirects=True))
+        self.assertEqual(rv.status_code, 302)
+
+    def test_login_after_regenerate_totp(self):
+        """Test that journalists can login after resetting their Google Authenticator 2fa"""
+
+        # regenerate totp
+        self.journalist_app.post('/account/reset-2fa-totp')
+      
+        # successful verification should redirect to /account
+        rv = self.journalist_app.post('/account/2fa', data=dict(
+            token=self.user.totp.now()))
+        self.assertEqual(rv.status_code, 302)
+      
+        # log out
+        common.logout(self.journalist_app)
+
+        # login with new 2fa secret should redirect to index page
+        rv = self.journalist_app.post('/login', data=dict(
+            username=self.user.username,
+            password=self.user_pw,
+            token=self.user.totp.now(),
+            follow_redirects=True))
+        self.assertEqual(rv.status_code, 302)
+
+    def test_login_after_regenerate_hotp(self):
+        """Test that journalists can login after resetting their HOTP 2fa"""
+
+        # edit hotp
+        self.journalist_app.post('/account/reset-2fa-hotp', data=dict(
+            otp_secret=123456))
+      
+        # successful verificaton should redirect to /account
+        rv = self.journalist_app.post('/account/2fa', data=dict(
+            token=self.user.hotp))
+        self.assertEqual(rv.status_code, 302)
+
+        # log out
+        common.logout(self.journalist_app)
+        
+        # login with new 2fa secret should redirect to index page
+        rv = self.journalist_app.post('/login', data=dict(
+            username=self.user.username,
+            password=self.user_pw,
+            token=self.user.hotp,
+            follow_redirects=True))
+        self.assertEqual(rv.status_code, 302)
+
     def helper_filenames_submit(self):
         self.source_app.post('/submit', data=dict(
             msg="This is a test.",
@@ -584,5 +649,6 @@ def helper_filenames_delete(self, soup, i):
         self._wait_for(lambda: self.assertFalse(
             any([os.path.exists(store.path(sid, doc_name)) for doc_name in checkbox_values])))
 
+
 if __name__ == "__main__":
     unittest.main(verbosity=2)
diff --git a/securedrop/tests/test_unit_journalist.py b/securedrop/tests/test_unit_journalist.py
--- a/securedrop/tests/test_unit_journalist.py
+++ b/securedrop/tests/test_unit_journalist.py
@@ -96,6 +96,17 @@ def test_admin_user_has_admin_link_in_index(self):
             "Admin")
         self.assertIn(admin_link, res.data)
 
+    def test_user_has_edit_account_link_in_index(self):
+        res = self.client.post(url_for('login'), data=dict(
+            username=self.user.username,
+            password=self.user_pw,
+            token=self.user.totp.now()),
+            follow_redirects=True)
+        edit_account_link = '<a href="{}">{}</a>'.format(
+            url_for('edit_account'),
+            "Edit Account")
+        self.assertIn(edit_account_link, res.data)
+
     def _login_user(self):
         self.client.post(url_for('login'), data=dict(
             username=self.user.username,
@@ -138,7 +149,7 @@ def test_admin_authorization_for_posts(self):
 
     def test_user_authorization_for_gets(self):
         urls = [url_for('index'), url_for('col', sid='1'),
-                url_for('doc', sid='1', fn='1')]
+                url_for('doc', sid='1', fn='1'), url_for('edit_account')]
 
         for url in urls:
             res = self.client.get(url)
@@ -147,11 +158,54 @@ def test_user_authorization_for_gets(self):
     def test_user_authorization_for_posts(self):
         urls = [url_for('add_star', sid='1'), url_for('remove_star', sid='1'),
                 url_for('col_process'), url_for('col_delete_single', sid='1'),
-                url_for('reply'), url_for('generate_code'), url_for('bulk')]
+                url_for('reply'), url_for('generate_code'), url_for('bulk'),
+                url_for('account_new_two_factor'), url_for('account_reset_two_factor_totp'),
+                url_for('account_reset_two_factor_hotp')]
         for url in urls:
             res = self.client.post(url)
             self.assert_status(res, 302)
 
+    def test_invalid_user_password_change(self):
+        self._login_user()
+        res = self.client.post(url_for('edit_account'), data=dict(
+            password='not',
+            password_again='thesame'))
+        self.assert_redirects(res, url_for('edit_account'))
+        
+    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)
+
+    def test_regenerate_totp(self):
+        self._login_user()
+        oldTotp = self.user.totp
+
+        res = self.client.post(url_for('account_reset_two_factor_totp'))
+        newTotp = self.user.totp
+
+        # check that totp is different
+        self.assertNotEqual(oldTotp, newTotp)
+        
+        # should redirect to verification page
+        self.assert_redirects(res, url_for('account_new_two_factor'))
+
+    def test_edit_hotp(self):
+        self._login_user()
+        oldHotp = self.user.hotp
+
+        res = self.client.post(url_for('account_reset_two_factor_hotp'), data=dict(
+            otp_secret=123456))
+        newHotp = self.user.hotp
+
+        # check that hotp is different 
+        self.assertNotEqual(oldHotp, newHotp)
+
+        # should redirect to verification page
+        self.assert_redirects(res, url_for('account_new_two_factor'))
+
     # TODO: more tests for admin interface
 
     def test_bulk_download(self):