diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 1f4c150d006..8f185da3cda 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 @@ -280,23 +281,38 @@ def get_current_user(): user = get_user_object(request) return jsonify(user.to_json()), 200 - @api.route('/account/new-password', methods=['POST']) + @api.route('/user/new-passphrase', methods=['POST']) @token_required - def new_password(): + def set_passphrase(): + REQUIRED_ATTRIBUTES = ['old_passphrase', 'two_factor_code', 'new_passphrase'] + user = get_user_object(request) - new_password = json.loads(request.data)['new_password'] + 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: - user.set_password(new_password) - except (InvalidPasswordLength, NonDicewarePassword) as e: - return jsonify({'message': str(e)}), 400 - except Exception as e: - return jsonify({'message': 'An error occurred while setting the password. Password unchanged'}), 500 + 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 Exception as e: - return jsonify({'message': 'An error occurred on database commit. Password unchanged.'}), 500 + 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 diff --git a/securedrop/models.py b/securedrop/models.py index 4a8797b9c4e..870d5d8f316 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -578,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, + 'set_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 77abeddf5e8..cac870fb4d3 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -663,20 +663,68 @@ def test_authorized_user_can_add_reply(journalist_app, journalist_api_token, assert reply_content == saved_content -def test_new_password_unacceptable_400(journalist_app, journalist_api_token, - test_journo): +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_password = 'a' * (Journalist.MIN_PASSWORD_LEN - 1) - data = {'new_password': new_password} - response = app.post(url_for('api.new_password'), + 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_password)) + response.get_json()['message'] == str(InvalidPasswordLength(new_passphrase)) ) with journalist_app.app_context(): @@ -684,16 +732,21 @@ def test_new_password_unacceptable_400(journalist_app, journalist_api_token, assert original_hash == user.passphrase_hash -def test_new_password_success_200(journalist_app, journalist_api_token, - test_journo): +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_password = 'a ' * max( (Journalist.MIN_PASSWORD_LEN+1) / 2, - Journalist.MIN_PASSWORD_WORDS) + 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 = {'new_password': new_password} - response = app.post(url_for('api.new_password'), + 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