diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index a80fa2a538..8f185da3cd 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta from functools import wraps +from six import string_types import json from werkzeug.exceptions import default_exceptions # type: ignore @@ -9,7 +10,8 @@ from journalist_app import utils from models import (Journalist, Reply, Source, Submission, LoginThrottledException, InvalidUsernameException, - BadTokenException, WrongPasswordException) + BadTokenException, WrongPasswordException, + InvalidPasswordLength, NonDicewarePassword) from store import NotEncrypted @@ -279,6 +281,41 @@ def get_current_user(): user = get_user_object(request) return jsonify(user.to_json()), 200 + @api.route('/user/new-passphrase', methods=['POST']) + @token_required + def set_passphrase(): + REQUIRED_ATTRIBUTES = ['old_passphrase', 'two_factor_code', 'new_passphrase'] + + user = get_user_object(request) + data = json.loads(request.data) + + # Validate request + for attribute in REQUIRED_ATTRIBUTES: + if attribute not in data: + return jsonify({'message': + 'Invalid request: {} unspecified'.format(attribute)}), 400 + elif not isinstance(data[attribute], string_types): + return jsonify({'message': + 'Invalid request: {} must be a string'}.format(attribute)), 400 + + try: + Journalist.login(user.username, data['old_passphrase'], data['two_factor_code']) + except (BadTokenException, WrongPasswordException): + return jsonify({'message': + 'Invalid credentials. Passphrase change denied'}), 403 + + # Set password + try: + user.set_password(data['new_passphrase']) + db.session.commit() + except (InvalidPasswordLength, NonDicewarePassword) as e: + return jsonify({'message': str(e)}), 400 + except Exception: + return jsonify({'message': + 'An error occurred while setting the passphrase. Passphrase unchanged'}), 500 + + return jsonify({'message': 'Password changed successfully'}), 200 + def _handle_http_exception(error): # Workaround for no blueprint-level 404/5 error handlers, see: # https://github.com/pallets/flask/issues/503#issuecomment-71383286 diff --git a/securedrop/models.py b/securedrop/models.py index 74420e9046..0835a6e975 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -314,6 +314,9 @@ class NonDicewarePassword(PasswordError): """Raised when attempting to validate a password that is not diceware-like """ + def __str__(self): + return ("Password needs to be a passphrase of at least " + "{} words.").format(Journalist.MIN_PASSWORD_WORDS) class Journalist(db.Model): @@ -361,6 +364,7 @@ def _scrypt_hash(self, password, salt): MAX_PASSWORD_LEN = 128 MIN_PASSWORD_LEN = 14 + MIN_PASSWORD_WORDS = 7 def set_password(self, passphrase): self.check_password_acceptable(passphrase) @@ -398,7 +402,7 @@ def check_password_acceptable(cls, password): raise InvalidPasswordLength(password) # Ensure all passwords are "diceware-like" - if len(password.split()) < 7: + if len(password.split()) < cls.MIN_PASSWORD_WORDS: raise NonDicewarePassword() def valid_password(self, passphrase): @@ -574,7 +578,8 @@ def to_json(self): 'username': self.username, 'last_login': self.last_access.isoformat() + 'Z', 'is_admin': self.is_admin, - 'uuid': self.uuid + 'uuid': self.uuid, + 'new_passphrase_url': url_for('api.set_passphrase') } return json_user diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 6e044b2cef..cac870fb4d 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -9,7 +9,8 @@ from itsdangerous import TimedJSONWebSignatureSerializer from db import db -from models import Journalist, Reply, Source, SourceStar, Submission +from models import (Journalist, Reply, Source, SourceStar, Submission, + NonDicewarePassword, InvalidPasswordLength) os.environ['SECUREDROP_ENV'] = 'test' # noqa from utils.api_helper import get_api_headers @@ -662,6 +663,99 @@ def test_authorized_user_can_add_reply(journalist_app, journalist_api_token, assert reply_content == saved_content +def test_set_passphrase_blank_request_400(journalist_app, journalist_api_token): + with journalist_app.test_client() as app: + response = app.post(url_for('api.set_passphrase'), + data='{}', + headers=get_api_headers(journalist_api_token)) + assert response.status_code == 400 + + +def test_set_passphrase_nonstring_request_400(journalist_app, journalist_api_token): + with journalist_app.test_client() as app: + data = {'new_passphrase': {}} + response = app.post(url_for('api.set_passphrase'), + data=json.dumps(data), + headers=get_api_headers(journalist_api_token)) + assert response.status_code == 400 + + +def test_set_passphrase_wrong_pass_403(journalist_app, journalist_api_token, + test_journo): + topt = test_journo['journalist'].totp + with journalist_app.test_client() as app: + data = {'old_passphrase': test_journo['password'] + 'a', + 'two_factor_code': topt.now(), + 'new_passphrase': 'aaaa'} + response = app.post(url_for('api.set_passphrase'), + data=json.dumps(data), + headers=get_api_headers(journalist_api_token)) + + assert response.status_code == 403 + + +def test_set_passphrase_wrong_otp_403(journalist_app, journalist_api_token, + test_journo): + topt = test_journo['journalist'].totp + with journalist_app.test_client() as app: + data = {'old_passphrase': test_journo['password'], + 'two_factor_code': topt.now() + '1', + 'new_passphrase': 'aaaa'} + response = app.post(url_for('api.set_passphrase'), + data=json.dumps(data), + headers=get_api_headers(journalist_api_token)) + + assert response.status_code == 403 + + +def test_set_passphrase_unacceptable_400(journalist_app, journalist_api_token, + test_journo): + original_hash = test_journo['journalist'].passphrase_hash + topt = test_journo['journalist'].totp + + with journalist_app.test_client() as app: + new_passphrase = 'a' * (Journalist.MIN_PASSWORD_LEN - 1) + data = {'old_passphrase': test_journo['password'], + 'two_factor_code': topt.now(), + 'new_passphrase': new_passphrase} + response = app.post(url_for('api.set_passphrase'), + data=json.dumps(data), + headers=get_api_headers(journalist_api_token)) + + assert response.status_code == 400 + assert (response.get_json()['message'] == str(NonDicewarePassword()) or + response.get_json()['message'] == str(InvalidPasswordLength(new_passphrase)) + ) + + with journalist_app.app_context(): + user = Journalist.query.get(test_journo['id']) + assert original_hash == user.passphrase_hash + + +def test_set_passphrase_success_200(journalist_app, journalist_api_token, + test_journo): + original_hash = test_journo['journalist'].passphrase_hash + topt = test_journo['journalist'].totp + + with journalist_app.test_client() as app: + new_passphrase = ('a ' * Journalist.MIN_PASSWORD_WORDS)[:-1] + pass_len_short = Journalist.MIN_PASSWORD_LEN - len(new_passphrase) + if pass_len_short > 0: + new_passphrase += 'a' * pass_len_short + + data = {'old_passphrase': test_journo['password'], + 'two_factor_code': topt.now(), + 'new_passphrase': new_passphrase} + response = app.post(url_for('api.set_passphrase'), + data=json.dumps(data), + headers=get_api_headers(journalist_api_token)) + assert response.status_code == 200 + + with journalist_app.app_context(): + user = Journalist.query.get(test_journo['id']) + assert original_hash != user.passphrase_hash + + def test_reply_without_content_400(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: