Repository: freedomofpress/securedrop

Issue:
User does not need to enter their old password to change their password
## Description

A user can change their password without entering their old password first. 

## Steps to Reproduce

1. Login
2. Go to change password form

## Expected Behavior

User must enter old password and then generate the new password.

## Actual Behavior

User can generate their new password without entering their old one. 

## Comments 

Related: #2300, #2303. This is a low priority to fix, but filing nevertheless. 


Discussion:
Proposing that the frontend change would look like the following (only when user edits their own account, not for admins), including the password and 2fa code:
<img width="710" alt="screen shot 2017-10-08 at 17 07 54" src="https://user-images.githubusercontent.com/1524722/31318057-40df067a-ac4c-11e7-8a2d-6ca5d9890ec1.png">

I'm hoping to get a pull request in later today.

Resolving patch:
diff --git a/securedrop/journalist.py b/securedrop/journalist.py
--- a/securedrop/journalist.py
+++ b/securedrop/journalist.py
@@ -26,7 +26,8 @@
 import template_filters
 from db import (db_session, Source, Journalist, Submission, Reply,
                 SourceStar, get_one_or_else, LoginThrottledException,
-                PasswordError, InvalidUsernameException)
+                PasswordError, InvalidUsernameException,
+                BadTokenException, WrongPasswordException)
 import worker
 
 app = Flask(__name__, template_folder=config.JOURNALIST_TEMPLATES_DIR)
@@ -131,42 +132,60 @@ def wrapper(*args, **kwargs):
     return wrapper
 
 
+def validate_user(username, password, token, error_message=None):
+    """
+    Validates the user by calling the login and handling exceptions
+    :param username: Username
+    :param password: Password
+    :param token: Two-factor authentication token
+    :param error_message: Localized error message string to use on failure
+    :return: Journalist user object if successful, None otherwise.
+    """
+    try:
+        return Journalist.login(username, password, token)
+    except (InvalidUsernameException,
+            BadTokenException,
+            WrongPasswordException,
+            LoginThrottledException) as e:
+        app.logger.error("Login for '{}' failed: {}".format(
+            username, e))
+        if not error_message:
+            error_message = gettext('Login failed.')
+        login_flashed_msg = error_message
+
+        if isinstance(e, LoginThrottledException):
+            login_flashed_msg += " "
+            period = Journalist._LOGIN_ATTEMPT_PERIOD
+            # ngettext is needed although we always have period > 1
+            # see https://github.com/freedomofpress/securedrop/issues/2422
+            login_flashed_msg += ngettext(
+                "Please wait at least {seconds} second "
+                "before logging in again.",
+                "Please wait at least {seconds} seconds "
+                "before logging in again.", period).format(seconds=period)
+        else:
+            try:
+                user = Journalist.query.filter_by(
+                    username=username).one()
+                if user.is_totp:
+                    login_flashed_msg += " "
+                    login_flashed_msg += gettext(
+                        "Please wait for a new two-factor token"
+                        " before trying again.")
+            except:
+                pass
+
+        flash(login_flashed_msg, "error")
+        return None
+
+
 @app.route('/login', methods=('GET', 'POST'))
 def login():
     if request.method == 'POST':
-        try:
-            user = Journalist.login(request.form['username'],
-                                    request.form['password'],
-                                    request.form['token'])
-        except Exception as e:
-            app.logger.error("Login for '{}' failed: {}".format(
-                request.form['username'], e))
-            login_flashed_msg = gettext('Login failed.')
-
-            if isinstance(e, LoginThrottledException):
-                login_flashed_msg += " "
-                period = Journalist._LOGIN_ATTEMPT_PERIOD
-                # ngettext is needed although we always have period > 1
-                # see https://github.com/freedomofpress/securedrop/issues/2422
-                login_flashed_msg += ngettext(
-                    "Please wait at least {seconds} second "
-                    "before logging in again.",
-                    "Please wait at least {seconds} seconds "
-                    "before logging in again.", period).format(seconds=period)
-            else:
-                try:
-                    user = Journalist.query.filter_by(
-                        username=request.form['username']).one()
-                    if user.is_totp:
-                        login_flashed_msg += " "
-                        login_flashed_msg += gettext(
-                            "Please wait for a new two-factor token"
-                            " before logging in again.")
-                except:
-                    pass
-
-            flash(login_flashed_msg, "error")
-        else:
+        user = validate_user(request.form['username'],
+                             request.form['password'],
+                             request.form['token'])
+        if user:
             app.logger.info("'{}' logged in with the token {}".format(
                 request.form['username'], request.form['token']))
 
@@ -408,12 +427,17 @@ def edit_account():
                            password=password)
 
 
-@app.route('/account/new-password', methods=['POST'])
+@app.route('/account/new-password', methods=('POST',))
 @login_required
 def new_password():
     user = g.user
-    password = request.form.get('password')
-    _set_diceware_password(user, password)
+    current_password = request.form.get('current_password')
+    token = request.form.get('token')
+    error_message = gettext('Incorrect password or two-factor code.')
+    # If the user is validated, change their password
+    if validate_user(user.username, current_password, token, error_message):
+        password = request.form.get('password')
+        _set_diceware_password(user, password)
     return redirect(url_for('edit_account'))
 
 


Test patch:
diff --git a/securedrop/tests/test_integration.py b/securedrop/tests/test_integration.py
--- a/securedrop/tests/test_integration.py
+++ b/securedrop/tests/test_integration.py
@@ -545,7 +545,9 @@ def test_user_change_password(self):
         # change password
         new_pw = 'another correct horse battery staply long password'
         self.journalist_app.post('/account/new-password',
-                                 data=dict(password=new_pw))
+                                 data=dict(password=new_pw,
+                                           current_password=self.user_pw,
+                                           token='mocked'))
 
         # logout
         self.journalist_app.get('/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
@@ -294,7 +294,9 @@ def test_user_edits_password_success_reponse(self):
         self._login_user()
         resp = self.client.post(
             url_for('new_password'),
-            data=dict(password=VALID_PASSWORD_2),
+            data=dict(current_password=self.user_pw,
+                      token='mocked',
+                      password=VALID_PASSWORD_2),
             follow_redirects=True)
 
         text = resp.data.decode('utf-8')
@@ -307,7 +309,9 @@ def test_user_edits_password_error_reponse(self):
         with patch('db.db_session.commit', side_effect=Exception()):
             resp = self.client.post(
                 url_for('new_password'),
-                data=dict(password=VALID_PASSWORD_2),
+                data=dict(current_password=self.user_pw,
+                          token='mocked',
+                          password=VALID_PASSWORD_2),
                 follow_redirects=True)
 
         assert ('There was an error, and the new password might not have '
@@ -361,7 +365,9 @@ def test_user_edits_password_too_long_warning(self):
             'a' * (Journalist.MAX_PASSWORD_LEN - len(VALID_PASSWORD) + 1)
 
         self.client.post(url_for('new_password'),
-                         data=dict(password=overly_long_password),
+                         data=dict(password=overly_long_password,
+                                   token='mocked',
+                                   current_password=self.user_pw),
                          follow_redirects=True)
 
         self.assertMessageFlashed('You submitted a bad password! '
@@ -662,10 +668,23 @@ def test_user_authorization_for_posts(self):
             res = self.client.post(url)
             self.assertStatus(res, 302)
 
+    def test_incorrect_current_password_change(self):
+        self._login_user()
+        resp = self.client.post(url_for('new_password'),
+                                data=dict(password=VALID_PASSWORD,
+                                          token='mocked',
+                                          current_password='badpw'),
+                                follow_redirects=True)
+
+        text = resp.data.decode('utf-8')
+        self.assertIn('Incorrect password or two-factor code', text)
+
     def test_invalid_user_password_change(self):
         self._login_user()
         res = self.client.post(url_for('new_password'),
-                               data=dict(password='badpw'))
+                               data=dict(password='badpw',
+                                         token='mocked',
+                                         current_password=self.user_pw))
         self.assertRedirects(res, url_for('edit_account'))
 
     def test_too_long_user_password_change(self):
@@ -675,7 +694,9 @@ def test_too_long_user_password_change(self):
             'a' * (Journalist.MAX_PASSWORD_LEN - len(VALID_PASSWORD) + 1)
 
         self.client.post(url_for('new_password'),
-                         data=dict(password=overly_long_password),
+                         data=dict(password=overly_long_password,
+                                   token='mocked',
+                                   current_password=self.user_pw),
                          follow_redirects=True)
 
         self.assertMessageFlashed('You submitted a bad password! Password not '
@@ -685,7 +706,9 @@ def test_valid_user_password_change(self):
         self._login_user()
         resp = self.client.post(
             url_for('new_password'),
-            data=dict(password=VALID_PASSWORD_2),
+            data=dict(password=VALID_PASSWORD_2,
+                      token='mocked',
+                      current_password=self.user_pw),
             follow_redirects=True)
 
         assert 'Password updated.' in \