From 4e90665ed34959c4542fe567913d3f13dfe9f54b Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 18 Jun 2018 11:51:08 -0700 Subject: [PATCH 01/71] Journalist API: Add initial Blueprint with root endpoint And an initial (pytest-based) unit test --- securedrop/journalist_app/__init__.py | 6 ++++-- securedrop/journalist_app/api.py | 18 ++++++++++++++++++ securedrop/tests/test_journalist_api.py | 21 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 securedrop/journalist_app/api.py create mode 100644 securedrop/tests/test_journalist_api.py diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index 6a3492e800..74ebf8b320 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -13,7 +13,7 @@ from crypto_util import CryptoUtil from db import db -from journalist_app import account, admin, main, col +from journalist_app import account, admin, api, main, col from journalist_app.utils import get_source, logged_in from models import Journalist from store import Storage @@ -27,7 +27,8 @@ # http://flake8.pycqa.org/en/latest/user/error-codes.html?highlight=f401 from sdconfig import SDConfig # noqa: F401 -_insecure_views = ['main.login', 'main.select_logo', 'static'] +_insecure_views = ['main.login', 'main.select_logo', 'static', + 'api.get_endpoints'] def create_app(config): @@ -144,5 +145,6 @@ def setup_g(): url_prefix='/account') app.register_blueprint(admin.make_blueprint(config), url_prefix='/admin') app.register_blueprint(col.make_blueprint(config), url_prefix='/col') + app.register_blueprint(api.make_blueprint(config), url_prefix='/api/v1') return app diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py new file mode 100644 index 0000000000..a502dc8ade --- /dev/null +++ b/securedrop/journalist_app/api.py @@ -0,0 +1,18 @@ +from flask import Blueprint, jsonify + + +import config + + +def make_blueprint(config): + api = Blueprint('api', __name__) + + @api.route('/') + def get_endpoints(): + endpoints = {'sources_url': '/api/v1/sources/', + 'current_user_url': '/api/v1/user/', + 'submissions_url': '/api/v1/submissions/', + 'auth_token_url': '/api/v1/token/'} + return jsonify(endpoints), 200 + + return api diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py new file mode 100644 index 0000000000..7212d555be --- /dev/null +++ b/securedrop/tests/test_journalist_api.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +import json +import os +import pytest + +from flask import url_for + +os.environ['SECUREDROP_ENV'] = 'test' # noqa +from sdconfig import SDConfig, config + + +def test_unauthenticated_user_gets_all_endpoints(journalist_app): + with journalist_app.test_client() as app: + response = app.get(url_for('api.get_endpoints'), + content_type='application/json') + + observed_endpoints = json.loads(response.data) + + for expected_endpoint in ['current_user_url', 'sources_url', + 'submissions_url']: + assert expected_endpoint in observed_endpoints.keys() From 8068543ea4cdc50317c9f66fc0bf67bdf32b9c29 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 18 Jun 2018 12:56:24 -0700 Subject: [PATCH 02/71] Remove deprecated autoversion --- securedrop/journalist_app/__init__.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index 74ebf8b320..0b6612f018 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -98,17 +98,6 @@ def handle_csrf_error(e): template_filters.rel_datetime_format app.jinja_env.filters['filesizeformat'] = template_filters.filesizeformat - @app.template_filter('autoversion') - def autoversion_filter(filename): - """Use this template filter for cache busting""" - absolute_filename = path.join(config.SECUREDROP_ROOT, filename[1:]) - if path.exists(absolute_filename): - timestamp = str(path.getmtime(absolute_filename)) - else: - return filename - versioned_filename = "{0}?v={1}".format(filename, timestamp) - return versioned_filename - @app.before_request def setup_g(): """Store commonly used values in Flask's special g object""" From b10dbe310401916c2dcf38e1abd47aac9614d3a9 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 18 Jun 2018 13:00:05 -0700 Subject: [PATCH 03/71] Journalist API: Rework login by default logic We have logic in __init__.py here to ensure that a developer does not accidentally forget to protect a route with a @login_required decorator. Instead of the decorator, we have a list of insecure views. We rework this to allow us to use a decorator for the API routes. --- securedrop/journalist_app/__init__.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index 0b6612f018..8eff93e514 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from datetime import datetime, timedelta -from flask import Flask, session, redirect, url_for, flash, g, request +from flask import abort, Flask, session, redirect, url_for, flash, g, request from flask_assets import Environment from flask_babel import gettext from flask_wtf.csrf import CSRFProtect, CSRFError @@ -27,8 +27,8 @@ # http://flake8.pycqa.org/en/latest/user/error-codes.html?highlight=f401 from sdconfig import SDConfig # noqa: F401 -_insecure_views = ['main.login', 'main.select_logo', 'static', - 'api.get_endpoints'] +_insecure_api_views = ['api.get_endpoints'] +_insecure_views = ['main.login', 'main.select_logo', 'static'] def create_app(config): @@ -120,8 +120,12 @@ def setup_g(): g.html_lang = i18n.locale_to_rfc_5646(g.locale) g.locales = i18n.get_locale2name() - if request.endpoint not in _insecure_views and not logged_in(): - return redirect(url_for('main.login')) + if str(request.endpoint).split('.')[0] == 'api': + if request.endpoint not in _insecure_api_views: + abort(403) + else: # We are not using the API + if request.endpoint not in _insecure_views and not logged_in(): + return redirect(url_for('main.login')) if request.method == 'POST': filesystem_id = request.form.get('filesystem_id') From 55241d955fd46a097cd59734c22045623f76235b Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 18 Jun 2018 13:54:03 -0700 Subject: [PATCH 04/71] Journalist interface: Add 403 error handler This is primarily for the API, but it turns out that we actually make use of abort(403) to prevent an admin from deleting themselves, so instead of seeing a Flask error page, they will see a nice error page in the style of the rest of the journalist interface. --- securedrop/journalist_app/__init__.py | 12 +++++++++++- securedrop/journalist_templates/403.html | 4 ++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 securedrop/journalist_templates/403.html diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index 8eff93e514..a01035fcd2 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- from datetime import datetime, timedelta -from flask import abort, Flask, session, redirect, url_for, flash, g, request +from flask import (abort, Flask, session, redirect, url_for, flash, g, request, + jsonify, render_template) from flask_assets import Environment from flask_babel import gettext from flask_wtf.csrf import CSRFProtect, CSRFError @@ -140,4 +141,13 @@ def setup_g(): app.register_blueprint(col.make_blueprint(config), url_prefix='/col') app.register_blueprint(api.make_blueprint(config), url_prefix='/api/v1') + @app.errorhandler(403) + def forbidden(message): + if request.headers['Content-Type'] == 'application/json': + response = jsonify({'error': 'forbidden', + 'message': 'Not authorized'}) + return response, 403 + else: + return render_template('403.html'), 403 + return app diff --git a/securedrop/journalist_templates/403.html b/securedrop/journalist_templates/403.html new file mode 100644 index 0000000000..783dffe10d --- /dev/null +++ b/securedrop/journalist_templates/403.html @@ -0,0 +1,4 @@ +{% extends "base.html" %} +{% block body %} +

Forbidden

+{% endblock %} From 0a7fcbb65b29f4a8d5e5785ade128666218646b7 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 18 Jun 2018 13:56:32 -0700 Subject: [PATCH 05/71] Journalist API: Add endpoint to do API token auth --- securedrop/journalist_app/__init__.py | 4 +- securedrop/journalist_app/api.py | 35 ++++++++++++++- securedrop/models.py | 15 +++++++ securedrop/tests/test_journalist_api.py | 58 +++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index a01035fcd2..8073a3433d 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -28,7 +28,7 @@ # http://flake8.pycqa.org/en/latest/user/error-codes.html?highlight=f401 from sdconfig import SDConfig # noqa: F401 -_insecure_api_views = ['api.get_endpoints'] +_insecure_api_views = ['api.get_endpoints', 'api.get_token'] _insecure_views = ['main.login', 'main.select_logo', 'static'] @@ -123,7 +123,7 @@ def setup_g(): if str(request.endpoint).split('.')[0] == 'api': if request.endpoint not in _insecure_api_views: - abort(403) + abort(403, 'API token is invalid or expired.') else: # We are not using the API if request.endpoint not in _insecure_views and not logged_in(): return redirect(url_for('main.login')) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index a502dc8ade..9565ff6688 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -1,7 +1,27 @@ -from flask import Blueprint, jsonify +import json +from flask import abort, Blueprint, jsonify, request import config +from models import Journalist + + +def token_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + try: + auth_header = request.headers.get('Authorization') + except: + return abort(403, 'API token not found in Authorization header.') + + if auth_header: + auth_token = auth_header.split(" ")[1] + else: + auth_token = '' + if not Journalist.verify_api_token(auth_token): + return abort(403, 'API token is invalid or expired.') + return f(*args, **kwargs) + return decorated_function def make_blueprint(config): @@ -15,4 +35,17 @@ def get_endpoints(): 'auth_token_url': '/api/v1/token/'} return jsonify(endpoints), 200 + @api.route('/token/', methods=['POST']) + def get_token(): + creds = json.loads(request.data) + username = creds['username'] + password = creds['password'] + one_time_code = creds['one_time_code'] + try: + journalist = Journalist.login(username, password, one_time_code) + return jsonify({'token': journalist.generate_api_token( + expiration=1800), 'expiration': 1800}), 200 + except: + return abort(403, 'Token authentication failed.') + return api diff --git a/securedrop/models.py b/securedrop/models.py index 0b099562a5..f76c26fe19 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -16,6 +16,7 @@ from StringIO import StringIO # type: ignore from flask import current_app +from itsdangerous import TimedJSONWebSignatureSerializer as Serializer from jinja2 import Markup from sqlalchemy import ForeignKey from sqlalchemy.orm import relationship, backref @@ -436,6 +437,20 @@ def login(cls, username, password, token): raise WrongPasswordException("invalid password") return user + def generate_api_token(self, expiration): + s = Serializer(current_app.config['SECRET_KEY'], + expires_in=expiration) + return s.dumps({'id': self.id}).decode('ascii') + + @staticmethod + def verify_api_token(token): + s = Serializer(current_app.config['SECRET_KEY']) + try: + data = s.loads(token) + except: + return None + return Journalist.query.get(data['id']) + class JournalistLoginAttempt(db.Model): diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 7212d555be..d65bfde51c 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -3,12 +3,23 @@ import os import pytest +from pyotp import TOTP + from flask import url_for +from models import Journalist + os.environ['SECUREDROP_ENV'] = 'test' # noqa from sdconfig import SDConfig, config +def get_api_headers(): + return { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + + def test_unauthenticated_user_gets_all_endpoints(journalist_app): with journalist_app.test_client() as app: response = app.get(url_for('api.get_endpoints'), @@ -19,3 +30,50 @@ def test_unauthenticated_user_gets_all_endpoints(journalist_app): for expected_endpoint in ['current_user_url', 'sources_url', 'submissions_url']: assert expected_endpoint in observed_endpoints.keys() + + +def test_valid_user_can_get_an_api_token(journalist_app, test_journo): + with journalist_app.test_client() as app: + valid_token = TOTP(test_journo['otp_secret']).now() + response = app.post(url_for('api.get_token'), + data=json.dumps( + {'username': test_journo['username'], + 'password': test_journo['password'], + 'one_time_code': valid_token}), + headers=get_api_headers()) + observed_response = json.loads(response.data) + + assert isinstance(Journalist.verify_api_token(observed_response['token']), + Journalist) is True + assert response.status_code == 200 + + +def test_user_cannot_get_an_api_token_with_wrong_password(journalist_app, + test_journo): + with journalist_app.test_client() as app: + valid_token = TOTP(test_journo['otp_secret']).now() + response = app.post(url_for('api.get_token'), + data=json.dumps( + {'username': test_journo['username'], + 'password': 'wrong password', + 'one_time_code': valid_token}), + headers=get_api_headers()) + observed_response = json.loads(response.data) + + assert response.status_code == 403 + assert observed_response['error'] == 'forbidden' + + +def test_user_cannot_get_an_api_token_with_wrong_2fa_token(journalist_app, + test_journo): + with journalist_app.test_client() as app: + response = app.post(url_for('api.get_token'), + data=json.dumps( + {'username': test_journo['username'], + 'password': test_journo['password'], + 'one_time_code': '123456'}), + headers=get_api_headers()) + observed_response = json.loads(response.data) + + assert response.status_code == 403 + assert observed_response['error'] == 'forbidden' From 34021551fc298300070072d737d8665d152ff3f8 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 18 Jun 2018 14:03:19 -0700 Subject: [PATCH 06/71] Journalist API: Use decorator for protected API endpoints --- securedrop/journalist_app/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index 8073a3433d..32e17d1e12 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -122,8 +122,7 @@ def setup_g(): g.locales = i18n.get_locale2name() if str(request.endpoint).split('.')[0] == 'api': - if request.endpoint not in _insecure_api_views: - abort(403, 'API token is invalid or expired.') + pass # We use the @token_required decorator for the API endpoints else: # We are not using the API if request.endpoint not in _insecure_views and not logged_in(): return redirect(url_for('main.login')) From efad30948475312dc7ab506dd3aaa16cc880a6be Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 18 Jun 2018 14:12:07 -0700 Subject: [PATCH 07/71] Journalist API: Add pytest fixture for journalist API token --- securedrop/tests/conftest.py | 17 +++++++++++++++++ securedrop/tests/test_journalist_api.py | 8 +------- securedrop/tests/utils/api_helper.py | 11 +++++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 securedrop/tests/utils/api_helper.py diff --git a/securedrop/tests/conftest.py b/securedrop/tests/conftest.py index 87acb151b1..689bcd39eb 100644 --- a/securedrop/tests/conftest.py +++ b/securedrop/tests/conftest.py @@ -4,6 +4,7 @@ import logging import os import io +import json import psutil import pytest import shutil @@ -11,6 +12,8 @@ import subprocess from ConfigParser import SafeConfigParser +from flask import url_for +from pyotp import TOTP os.environ['SECUREDROP_ENV'] = 'test' # noqa from sdconfig import SDConfig, config as original_config @@ -166,6 +169,20 @@ def test_source(journalist_app): 'filesystem_id': filesystem_id} +@pytest.fixture(scope='function') +def journalist_api_token(journalist_app, test_journo): + with journalist_app.test_client() as app: + valid_token = TOTP(test_journo['otp_secret']).now() + response = app.post(url_for('api.get_token'), + data=json.dumps( + {'username': test_journo['username'], + 'password': test_journo['password'], + 'one_time_code': valid_token}), + headers=utils.api_helper.get_api_headers()) + observed_response = json.loads(response.data) + return observed_response['token'] + + def _start_test_rqworker(config): if not psutil.pid_exists(_get_pid_from_file(TEST_WORKER_PIDFILE)): tmp_logfile = io.open('/tmp/test_rqworker.log', 'w') diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index d65bfde51c..549981d229 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -11,13 +11,7 @@ os.environ['SECUREDROP_ENV'] = 'test' # noqa from sdconfig import SDConfig, config - - -def get_api_headers(): - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - } +from utils.api_helper import get_api_headers def test_unauthenticated_user_gets_all_endpoints(journalist_app): diff --git a/securedrop/tests/utils/api_helper.py b/securedrop/tests/utils/api_helper.py new file mode 100644 index 0000000000..9330dc4a23 --- /dev/null +++ b/securedrop/tests/utils/api_helper.py @@ -0,0 +1,11 @@ +def get_api_headers(token=''): + if token: + return { + 'Authorization': 'Token {}'.format(token), + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + return { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } From 98d43d29c37cef0143ba19282fe7693faa5f8f53 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 18 Jun 2018 14:57:45 -0700 Subject: [PATCH 08/71] Journalist API /sources/: placeholders and get_all_sources() --- securedrop/journalist_app/api.py | 48 ++++++++++++++++++++++++- securedrop/models.py | 22 +++++++++++- securedrop/tests/test_journalist_api.py | 15 ++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 9565ff6688..fed37e6816 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -1,9 +1,10 @@ +from functools import wraps import json from flask import abort, Blueprint, jsonify, request import config -from models import Journalist +from models import Journalist, Source def token_required(f): @@ -48,4 +49,49 @@ def get_token(): except: return abort(403, 'Token authentication failed.') + @api.route('/sources/', methods=['GET']) + @token_required + def get_all_sources(): + sources = Source.query.all() + return jsonify( + {'sources': [source.to_json() for source in sources]}), 200 + + @api.route('/sources//', methods=['GET']) + @token_required + def single_source(source_id): + pass + + @api.route('/sources//add_star/', methods=['POST']) + @token_required + def add_star(source_id): + pass + + @api.route('/sources//remove_star/', methods=['DELETE']) + @token_required + def remove_star(source_id): + pass + + @api.route('/sources//submissions/', methods=['GET', + 'DELETE']) + @token_required + def all_source_submissions(source_id): + pass + + @api.route('/sources//submissions//download/', # noqa + methods=['GET']) + @token_required + def download_submission(source_id, submission_id): + pass + + @api.route('/sources//submissions//', + methods=['GET', 'DELETE']) + @token_required + def single_submission(source_id, submission_id): + pass + + @api.route('/sources//reply/', methods=['POST']) + @token_required + def post_reply(source_id): + pass + return api diff --git a/securedrop/models.py b/securedrop/models.py index f76c26fe19..628080edf2 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -15,7 +15,7 @@ except ImportError: from StringIO import StringIO # type: ignore -from flask import current_app +from flask import current_app, url_for from itsdangerous import TimedJSONWebSignatureSerializer as Serializer from jinja2 import Markup from sqlalchemy import ForeignKey @@ -103,6 +103,26 @@ def collection(self): collection.sort(key=lambda x: int(x.filename.split('-')[0])) return collection + def to_json(self): + docs_msg_count = self.documents_messages_count() + + json_source = { + 'url': url_for('api.single_source', source_id=self.id), + 'source_id': self.id, + 'journalist_designation': self.journalist_designation, + 'flagged': self.flagged, + 'last_updated': self.last_updated, + 'interaction_count': self.interaction_count, + 'number_of_documents': docs_msg_count['documents'], + 'number_of_messages': docs_msg_count['messages'], + 'submissions_url': url_for('api.all_source_submissions', + source_id=self.id), + 'add_star_url': url_for('api.add_star', source_id=self.id), + 'remove_star_url': url_for('api.remove_star', source_id=self.id), + 'reply_url': url_for('api.post_reply', source_id=self.id) + } + return json_source + class Submission(db.Model): __tablename__ = 'submissions' diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 549981d229..8e25b06329 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -71,3 +71,18 @@ def test_user_cannot_get_an_api_token_with_wrong_2fa_token(journalist_app, assert response.status_code == 403 assert observed_response['error'] == 'forbidden' + + +def test_authorized_user_gets_all_sources(journalist_app, test_source, + journalist_api_token): + with journalist_app.test_client() as app: + response = app.get(url_for('api.get_all_sources'), + headers=get_api_headers(journalist_api_token)) + + data = json.loads(response.data) + + assert response.status_code == 200 + + # We expect to see our test source in the response + assert test_source['source'].journalist_designation == \ + data['sources'][0]['journalist_designation'] From efe2726145ad91bdd2cd9459f35f18a828dbe1be Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 18 Jun 2018 15:18:37 -0700 Subject: [PATCH 09/71] Test source pytest fixture: Add submissions for convenience --- securedrop/tests/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/securedrop/tests/conftest.py b/securedrop/tests/conftest.py index 689bcd39eb..fdbc218575 100644 --- a/securedrop/tests/conftest.py +++ b/securedrop/tests/conftest.py @@ -164,9 +164,11 @@ def test_source(journalist_app): with journalist_app.app_context(): source, codename = utils.db_helper.init_source() filesystem_id = source.filesystem_id + utils.db_helper.submit(source, 2) return {'source': source, 'codename': codename, - 'filesystem_id': filesystem_id} + 'filesystem_id': filesystem_id, + 'submissions': source.submissions} @pytest.fixture(scope='function') From dac442c16f1d85788b9cf68004250a3ba7e11137 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 18 Jun 2018 15:22:59 -0700 Subject: [PATCH 10/71] Journalist API: Add security test cases for HTTP GETs --- securedrop/tests/test_journalist_api.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 8e25b06329..2cf75b44f5 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -86,3 +86,27 @@ def test_authorized_user_gets_all_sources(journalist_app, test_source, # We expect to see our test source in the response assert test_source['source'].journalist_designation == \ data['sources'][0]['journalist_designation'] + + +def test_user_without_token_cannot_get_protected_endpoints(journalist_app, + test_source): + with journalist_app.app_context(): + protected_routes = [ + url_for('api.get_all_sources'), + url_for('api.single_source', source_id=test_source['source'].id), + url_for('api.all_source_submissions', + source_id=test_source['source'].id), + url_for('api.single_submission', + source_id=test_source['source'].id, + submission_id=test_source['submissions'][0].id), + url_for('api.download_submission', + source_id=test_source['source'].id, + submission_id=test_source['submissions'][0].id), + ] + + with journalist_app.test_client() as app: + for protected_route in protected_routes: + response = app.get(protected_route, + headers=get_api_headers('')) + + assert response.status_code == 403 From 79e5b609001392f5bd654f2a4660a2f825dd77d0 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 18 Jun 2018 15:33:59 -0700 Subject: [PATCH 11/71] Journalist API: Security test cases for HTTP DELETEs --- securedrop/tests/test_journalist_api.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 2cf75b44f5..c06ebd6b25 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -110,3 +110,24 @@ def test_user_without_token_cannot_get_protected_endpoints(journalist_app, headers=get_api_headers('')) assert response.status_code == 403 + + +def test_user_without_token_cannot_delete_protected_endpoints(journalist_app, + test_source): + with journalist_app.app_context(): + protected_routes = [ + url_for('api.all_source_submissions', + source_id=test_source['source'].id), + url_for('api.single_submission', + source_id=test_source['source'].id, + submission_id=test_source['submissions'][0].id), + url_for('api.remove_star', + source_id=test_source['source'].id), + ] + + with journalist_app.test_client() as app: + for protected_route in protected_routes: + response = app.delete(protected_route, + headers=get_api_headers('')) + + assert response.status_code == 403 From f6a42c5554f355bbb20c967106016e1a5562c460 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 18 Jun 2018 15:47:04 -0700 Subject: [PATCH 12/71] Journalist API: Add security test cases for HTTP POSTs --- securedrop/tests/test_journalist_api.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index c06ebd6b25..8034f14b0f 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -131,3 +131,18 @@ def test_user_without_token_cannot_delete_protected_endpoints(journalist_app, headers=get_api_headers('')) assert response.status_code == 403 + + +def test_user_without_token_cannot_post_protected_endpoints(journalist_app, + test_source): + with journalist_app.app_context(): + protected_routes = [ + url_for('api.post_reply', source_id=test_source['source'].id), + url_for('api.add_star', source_id=test_source['source'].id) + ] + + with journalist_app.test_client() as app: + for protected_route in protected_routes: + response = app.post(protected_route, + headers=get_api_headers('')) + assert response.status_code == 403 From 35d032e7d20ff435d9e0672e5aa8aefa87a8f9d1 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 18 Jun 2018 16:05:29 -0700 Subject: [PATCH 13/71] Journalist interface: Add 404 error handler Again, mostly for the journalist API, but users who are logged into the webapp will now see a custom error page with the regular SecureDrop styling instead of the default Flask page --- securedrop/journalist_app/__init__.py | 11 ++++++++++- securedrop/journalist_templates/404.html | 4 ++++ securedrop/tests/test_journalist_api.py | 10 ++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 securedrop/journalist_templates/404.html diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index 32e17d1e12..3b3ee7f474 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -121,7 +121,7 @@ def setup_g(): g.html_lang = i18n.locale_to_rfc_5646(g.locale) g.locales = i18n.get_locale2name() - if str(request.endpoint).split('.')[0] == 'api': + if request.path.split('/')[1] == 'api': pass # We use the @token_required decorator for the API endpoints else: # We are not using the API if request.endpoint not in _insecure_views and not logged_in(): @@ -149,4 +149,13 @@ def forbidden(message): else: return render_template('403.html'), 403 + @app.errorhandler(404) + def not_found(message): + if request.headers['Content-Type'] == 'application/json': + response = jsonify({'error': 'not found', + 'message': 'we could not find that resource'}) + return response, 404 + else: + return render_template('404.html'), 404 + return app diff --git a/securedrop/journalist_templates/404.html b/securedrop/journalist_templates/404.html new file mode 100644 index 0000000000..62e0c97975 --- /dev/null +++ b/securedrop/journalist_templates/404.html @@ -0,0 +1,4 @@ +{% extends "base.html" %} +{% block body %} +

Not Found

+{% endblock %} diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 8034f14b0f..ef84b8720b 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -146,3 +146,13 @@ def test_user_without_token_cannot_post_protected_endpoints(journalist_app, response = app.post(protected_route, headers=get_api_headers('')) assert response.status_code == 403 + + +def test_api_404(journalist_app, journalist_api_token): + with journalist_app.test_client() as app: + response = app.get('/api/v1/invalidendpoint', + headers=get_api_headers(journalist_api_token)) + json_response = json.loads(response.data) + + assert response.status_code == 404 + assert json_response['error'] == 'not found' From d32da8d1df13e97a1ebf429d21b8fc3b1d0e2ec9 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Fri, 22 Jun 2018 17:47:33 -0700 Subject: [PATCH 14/71] Journalist API: Add single source endpoint (/sources/) --- securedrop/journalist_app/api.py | 10 +++++++++- securedrop/tests/test_journalist_api.py | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index fed37e6816..0bc257a9ca 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -25,6 +25,13 @@ def decorated_function(*args, **kwargs): return decorated_function +def get_or_404(model, object_id): + result = model.query.get(object_id) + if result is None: + abort(404) + return result + + def make_blueprint(config): api = Blueprint('api', __name__) @@ -59,7 +66,8 @@ def get_all_sources(): @api.route('/sources//', methods=['GET']) @token_required def single_source(source_id): - pass + source = get_or_404(Source, source_id) + return jsonify(source.to_json()), 200 @api.route('/sources//add_star/', methods=['POST']) @token_required diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index ef84b8720b..894831de70 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -156,3 +156,25 @@ def test_api_404(journalist_app, journalist_api_token): assert response.status_code == 404 assert json_response['error'] == 'not found' + + +def test_authorized_user_gets_single_source(journalist_app, test_source, + journalist_api_token): + with journalist_app.test_client() as app: + response = app.get(url_for('api.single_source', + source_id=test_source['source'].id), + headers=get_api_headers(journalist_api_token)) + + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['source_id'] == test_source['source'].id + + +def test_get_non_existant_source_404s(journalist_app, journalist_api_token): + with journalist_app.test_client() as app: + response = app.get(url_for('api.single_source', + source_id=1), + headers=get_api_headers(journalist_api_token)) + + assert response.status_code == 404 From 73e0aa797912d2996cf22c6dc643d9e3aa948030 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Fri, 22 Jun 2018 18:34:26 -0700 Subject: [PATCH 15/71] Journalist API: Add star endpoint: /sources//star/ Covers cases where: * Source does not exist (404 response) * Star is successfully added (201 response) --- securedrop/journalist_app/api.py | 7 ++++++- securedrop/tests/test_journalist_api.py | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 0bc257a9ca..be0a60455d 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -4,6 +4,8 @@ from flask import abort, Blueprint, jsonify, request import config +from db import db +from journalist_app import utils from models import Journalist, Source @@ -72,7 +74,10 @@ def single_source(source_id): @api.route('/sources//add_star/', methods=['POST']) @token_required def add_star(source_id): - pass + source = get_or_404(Source, source_id) + utils.make_star_true(source.filesystem_id) + db.session.commit() + return jsonify({'message': 'Star added'}), 201 @api.route('/sources//remove_star/', methods=['DELETE']) @token_required diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 894831de70..8f20a1292d 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -7,7 +7,7 @@ from flask import url_for -from models import Journalist +from models import Journalist, SourceStar os.environ['SECUREDROP_ENV'] = 'test' # noqa from sdconfig import SDConfig, config @@ -178,3 +178,17 @@ def test_get_non_existant_source_404s(journalist_app, journalist_api_token): headers=get_api_headers(journalist_api_token)) assert response.status_code == 404 + + +def test_authorized_user_can_star_a_source(journalist_app, test_source, + journalist_api_token): + with journalist_app.test_client() as app: + source_id = test_source['source'].id + response = app.post(url_for('api.add_star', source_id=source_id), + headers=get_api_headers(journalist_api_token)) + + assert response.status_code == 201 + + # Verify that the source was starred. + assert SourceStar.query.filter( + SourceStar.source_id == source_id).one().starred From 122664e6b8d0af59f2ea10448dc0a46428f54c46 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Fri, 22 Jun 2018 18:42:59 -0700 Subject: [PATCH 16/71] Journalist API: Remove star endpoint: /sources//star/ HTTP DELETE will remove the star --- securedrop/journalist_app/api.py | 5 ++++- securedrop/tests/test_journalist_api.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index be0a60455d..3855165dba 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -82,7 +82,10 @@ def add_star(source_id): @api.route('/sources//remove_star/', methods=['DELETE']) @token_required def remove_star(source_id): - pass + source = get_or_404(Source, source_id) + utils.make_star_false(source.filesystem_id) + db.session.commit() + return jsonify({'message': 'Star removed'}), 200 @api.route('/sources//submissions/', methods=['GET', 'DELETE']) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 8f20a1292d..1b3dd5b048 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -192,3 +192,20 @@ def test_authorized_user_can_star_a_source(journalist_app, test_source, # Verify that the source was starred. assert SourceStar.query.filter( SourceStar.source_id == source_id).one().starred + + +def test_authorized_user_can_unstar_a_source(journalist_app, test_source, + journalist_api_token): + with journalist_app.test_client() as app: + source_id = test_source['source'].id + response = app.post(url_for('api.add_star', source_id=source_id), + headers=get_api_headers(journalist_api_token)) + assert response.status_code == 201 + + response = app.delete(url_for('api.remove_star', source_id=source_id), + headers=get_api_headers(journalist_api_token)) + assert response.status_code == 200 + + # Verify that the source is gone. + assert SourceStar.query.filter( + SourceStar.source_id == source_id).one().starred is False From 8b425db4ce122cc82284352bae020c9e4d92363c Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Fri, 22 Jun 2018 18:55:12 -0700 Subject: [PATCH 17/71] Journalist API: Catch itsdangerous.BadData only This was a bare except, but I think instead we want to handle only itsdangerous.BadData exceptions, which is a general exception that includes a bad signature and an expired one. --- securedrop/models.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/securedrop/models.py b/securedrop/models.py index 628080edf2..fabbdba0e0 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -16,7 +16,7 @@ from StringIO import StringIO # type: ignore from flask import current_app, url_for -from itsdangerous import TimedJSONWebSignatureSerializer as Serializer +from itsdangerous import TimedJSONWebSignatureSerializer, BadData from jinja2 import Markup from sqlalchemy import ForeignKey from sqlalchemy.orm import relationship, backref @@ -458,16 +458,16 @@ def login(cls, username, password, token): return user def generate_api_token(self, expiration): - s = Serializer(current_app.config['SECRET_KEY'], - expires_in=expiration) + s = TimedJSONWebSignatureSerializer( + current_app.config['SECRET_KEY'], expires_in=expiration) return s.dumps({'id': self.id}).decode('ascii') @staticmethod def verify_api_token(token): - s = Serializer(current_app.config['SECRET_KEY']) + s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY']) try: data = s.loads(token) - except: + except BadData: return None return Journalist.query.get(data['id']) From 8ae05b1f9c28fe28d1744e656a299227891a1d41 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Fri, 22 Jun 2018 19:07:30 -0700 Subject: [PATCH 18/71] Journalist interface: Add 405 error handler We need to also have a nice error handler for method not allowed, that should return JSON for the API and an HTML page for users of the regular web application. --- securedrop/journalist_app/__init__.py | 9 +++++++++ securedrop/journalist_templates/405.html | 4 ++++ securedrop/tests/test_journalist_api.py | 12 ++++++++++++ 3 files changed, 25 insertions(+) create mode 100644 securedrop/journalist_templates/405.html diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index 3b3ee7f474..7ec11b7061 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -158,4 +158,13 @@ def not_found(message): else: return render_template('404.html'), 404 + @app.errorhandler(405) + def method_not_allowed(message): + if request.headers['Content-Type'] == 'application/json': + response = jsonify({'error': 'method not allowed', + 'message': 'Not allowed'}) + return response, 405 + else: + return render_template('405.html'), 405 + return app diff --git a/securedrop/journalist_templates/405.html b/securedrop/journalist_templates/405.html new file mode 100644 index 0000000000..06caea83e3 --- /dev/null +++ b/securedrop/journalist_templates/405.html @@ -0,0 +1,4 @@ +{% extends "base.html" %} +{% block body %} +

Method not allowed

+{% endblock %} diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 1b3dd5b048..2313e0d6e3 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -209,3 +209,15 @@ def test_authorized_user_can_unstar_a_source(journalist_app, test_source, # Verify that the source is gone. assert SourceStar.query.filter( SourceStar.source_id == source_id).one().starred is False + + +def test_disallowed_methods_produces_405(journalist_app, test_source, + journalist_api_token): + with journalist_app.test_client() as app: + source_id = test_source['source'].id + response = app.delete(url_for('api.add_star', source_id=source_id), + headers=get_api_headers(journalist_api_token)) + json_response = json.loads(response.data) + + assert response.status_code == 405 + assert json_response['error'] == 'method not allowed' From 3504688e5ec29294b5a1e47de4d90c31baeb77fa Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Sat, 23 Jun 2018 00:18:50 -0700 Subject: [PATCH 19/71] Journalist API: Get all submissions [/submissions/] --- securedrop/journalist_app/api.py | 9 ++++++++- securedrop/models.py | 17 +++++++++++++++++ securedrop/tests/test_journalist_api.py | 20 +++++++++++++++++++- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 3855165dba..c7f39b4d91 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -6,7 +6,7 @@ import config from db import db from journalist_app import utils -from models import Journalist, Source +from models import Journalist, Source, Submission def token_required(f): @@ -110,4 +110,11 @@ def single_submission(source_id, submission_id): def post_reply(source_id): pass + @api.route('/submissions/', methods=['GET']) + @token_required + def get_all_submissions(): + submissions = Submission.query.all() + return jsonify({'submissions': [submission.to_json() for \ + submission in submissions]}), 200 + return api diff --git a/securedrop/models.py b/securedrop/models.py index fabbdba0e0..7ea4d4db28 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -146,6 +146,23 @@ def __init__(self, source, filename): def __repr__(self): return '' % (self.filename) + def to_json(self): + json_submission = { + 'source_url': url_for('api.single_source', + source_id=self.source_id), + 'submission_url': url_for('api.single_submission', + source_id=self.source_id, + submission_id=self.id), + 'submission_id': self.id, + 'filename': self.filename, + 'size': self.size, + 'is_read': self.downloaded, + 'download_url': url_for('api.download_submission', + source_id=self.source_id, + submission_id=self.id), + } + return json_submission + class Reply(db.Model): __tablename__ = "replies" diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 2313e0d6e3..64f51c4230 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -7,7 +7,7 @@ from flask import url_for -from models import Journalist, SourceStar +from models import Journalist, SourceStar, Submission os.environ['SECUREDROP_ENV'] = 'test' # noqa from sdconfig import SDConfig, config @@ -102,6 +102,7 @@ def test_user_without_token_cannot_get_protected_endpoints(journalist_app, url_for('api.download_submission', source_id=test_source['source'].id, submission_id=test_source['submissions'][0].id), + url_for('api.get_all_submissions') ] with journalist_app.test_client() as app: @@ -221,3 +222,20 @@ def test_disallowed_methods_produces_405(journalist_app, test_source, assert response.status_code == 405 assert json_response['error'] == 'method not allowed' + + +def test_authorized_user_can_get_all_submissions(journalist_app, test_source, + journalist_api_token): + with journalist_app.test_client() as app: + response = app.get(url_for('api.get_all_submissions'), + headers=get_api_headers(journalist_api_token)) + assert response.status_code == 200 + + json_response = json.loads(response.data) + + observed_submissions = [submission['filename'] for \ + submission in json_response['submissions']] + + expected_submissions = [submission.filename for \ + submission in Submission.query.all()] + assert observed_submissions == expected_submissions From 13246875352af1257c16d4161d0db9bbe9bb51fa Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Sat, 23 Jun 2018 00:33:04 -0700 Subject: [PATCH 20/71] Journalist API: GET a source's submissions [/source/:id/submissions] --- securedrop/journalist_app/api.py | 6 +++++- securedrop/tests/test_journalist_api.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index c7f39b4d91..301afe3aea 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -91,7 +91,11 @@ def remove_star(source_id): 'DELETE']) @token_required def all_source_submissions(source_id): - pass + if request.method == 'GET': + source = get_or_404(Source, source_id) + return jsonify( + {'submissions': [submission.to_json() for \ + submission in source.submissions]}), 200 @api.route('/sources//submissions//download/', # noqa methods=['GET']) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 64f51c4230..cfc8488103 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -239,3 +239,22 @@ def test_authorized_user_can_get_all_submissions(journalist_app, test_source, expected_submissions = [submission.filename for \ submission in Submission.query.all()] assert observed_submissions == expected_submissions + + +def test_authorized_user_get_source_submissions(journalist_app, test_source, + journalist_api_token): + with journalist_app.test_client() as app: + source_id = test_source['source'].id + response = app.get(url_for('api.all_source_submissions', + source_id=source_id), + headers=get_api_headers(journalist_api_token)) + assert response.status_code == 200 + + json_response = json.loads(response.data) + + observed_submissions = [submission['filename'] for \ + submission in json_response['submissions']] + + expected_submissions = [submission.filename for submission in \ + test_source['source'].submissions] + assert observed_submissions == expected_submissions From b4e9bb28620c24656bd407d383d377dfd103cdb2 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Sat, 23 Jun 2018 00:44:59 -0700 Subject: [PATCH 21/71] Journalist API: GET a single submission [/sources/:id/submissions/:id] --- securedrop/journalist_app/api.py | 4 +++- securedrop/tests/test_journalist_api.py | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 301afe3aea..655df21500 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -107,7 +107,9 @@ def download_submission(source_id, submission_id): methods=['GET', 'DELETE']) @token_required def single_submission(source_id, submission_id): - pass + if request.method == 'GET': + submission = get_or_404(Submission, submission_id) + return jsonify(submission.to_json()), 200 @api.route('/sources//reply/', methods=['POST']) @token_required diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index cfc8488103..d771a3b022 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -258,3 +258,26 @@ def test_authorized_user_get_source_submissions(journalist_app, test_source, expected_submissions = [submission.filename for submission in \ test_source['source'].submissions] assert observed_submissions == expected_submissions + + +def test_authorized_user_can_get_single_submission(journalist_app, + test_source, + journalist_api_token): + with journalist_app.test_client() as app: + submission_id = test_source['source'].submissions[0].id + source_id = test_source['source'].id + response = app.get(url_for('api.single_submission', + source_id=source_id, + submission_id=submission_id), + headers=get_api_headers(journalist_api_token)) + + assert response.status_code == 200 + + json_response = json.loads(response.data) + + assert json_response['submission_id'] == submission_id + assert json_response['is_read'] is False + assert json_response['filename'] == \ + test_source['source'].submissions[0].filename + assert json_response['size'] == \ + test_source['source'].submissions[0].size From d2c1ce35b681e7e79bcb16c0fcd57ec13f4de32c Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Sat, 23 Jun 2018 01:42:46 -0700 Subject: [PATCH 22/71] Journalist API: DELETE individual source submission --- securedrop/journalist_app/api.py | 6 ++++++ securedrop/journalist_app/utils.py | 12 ++++++++---- securedrop/tests/test_journalist_api.py | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 655df21500..3b7f240a29 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -110,6 +110,12 @@ def single_submission(source_id, submission_id): if request.method == 'GET': submission = get_or_404(Submission, submission_id) return jsonify(submission.to_json()), 200 + elif request.method == 'DELETE': + submission = get_or_404(Submission, submission_id) + source = get_or_404(Source, source_id) + utils.delete_file(source.filesystem_id, submission.filename, + submission) + return jsonify({'message': 'Submission deleted'}), 200 @api.route('/sources//reply/', methods=['POST']) @token_required diff --git a/securedrop/journalist_app/utils.py b/securedrop/journalist_app/utils.py index ef14fdf504..cc3a3bf33f 100644 --- a/securedrop/journalist_app/utils.py +++ b/securedrop/journalist_app/utils.py @@ -170,12 +170,16 @@ def download(zip_basename, submissions): as_attachment=True) +def delete_file(filesystem_id, filename, file_object): + file_path = current_app.storage.path(filesystem_id, filename) + worker.enqueue(srm, file_path) + db.session.delete(file_object) + db.session.commit() + + def bulk_delete(filesystem_id, items_selected): for item in items_selected: - item_path = current_app.storage.path(filesystem_id, item.filename) - worker.enqueue(srm, item_path) - db.session.delete(item) - db.session.commit() + delete_file(filesystem_id, item.filename, item) flash(ngettext("Submission deleted.", "{num} submissions deleted.".format( diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index d771a3b022..42142aa58a 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -281,3 +281,21 @@ def test_authorized_user_can_get_single_submission(journalist_app, test_source['source'].submissions[0].filename assert json_response['size'] == \ test_source['source'].submissions[0].size + + +def test_authorized_user_can_delete_single_submission(journalist_app, + test_source, + journalist_api_token): + with journalist_app.test_client() as app: + submission_id = test_source['source'].submissions[0].id + source_id = test_source['source'].id + response = app.delete(url_for('api.single_submission', + source_id=source_id, + submission_id=submission_id), + headers=get_api_headers(journalist_api_token)) + + assert response.status_code == 200 + + # Submission now should be gone. + assert Submission.query.filter( + Submission.id == submission_id).all() == [] From 1fef4dabd7fc97002b71bf364c2a26f303f20a8b Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Sat, 23 Jun 2018 01:57:58 -0700 Subject: [PATCH 23/71] Journalist API: DELETE source collection --- securedrop/journalist_app/api.py | 4 ++++ securedrop/tests/test_journalist_api.py | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 3b7f240a29..07b486da4a 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -96,6 +96,10 @@ def all_source_submissions(source_id): return jsonify( {'submissions': [submission.to_json() for \ submission in source.submissions]}), 200 + elif request.method == 'DELETE': + source = get_or_404(Source, source_id) + utils.delete_collection(source.filesystem_id) + return jsonify({'message': 'Source and submissions deleted'}), 200 @api.route('/sources//submissions//download/', # noqa methods=['GET']) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 42142aa58a..add6123167 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -7,7 +7,7 @@ from flask import url_for -from models import Journalist, SourceStar, Submission +from models import Journalist, Source, SourceStar, Submission os.environ['SECUREDROP_ENV'] = 'test' # noqa from sdconfig import SDConfig, config @@ -299,3 +299,19 @@ def test_authorized_user_can_delete_single_submission(journalist_app, # Submission now should be gone. assert Submission.query.filter( Submission.id == submission_id).all() == [] + + +def test_authorized_user_can_delete_source_collection(journalist_app, + test_source, + journalist_api_token): + with journalist_app.test_client() as app: + submission_id = test_source['source'].submissions[0].id + source_id = test_source['source'].id + response = app.delete(url_for('api.all_source_submissions', + source_id=source_id), + headers=get_api_headers(journalist_api_token)) + + assert response.status_code == 200 + + # Source does not exist + assert Source.query.all() == [] From 15baf2438c61f88cee49ea3fe4c2ec5fcc75117b Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Sat, 23 Jun 2018 02:08:26 -0700 Subject: [PATCH 24/71] Journalist API: Add endpoint to download PGP submissions --- securedrop/journalist_app/api.py | 14 ++++++++++++-- securedrop/tests/test_journalist_api.py | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 07b486da4a..9b3546aa8b 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -1,7 +1,7 @@ from functools import wraps import json -from flask import abort, Blueprint, jsonify, request +from flask import abort, Blueprint, current_app, jsonify, request, send_file import config from db import db @@ -105,7 +105,17 @@ def all_source_submissions(source_id): methods=['GET']) @token_required def download_submission(source_id, submission_id): - pass + source = get_or_404(Source, source_id) + submission = get_or_404(Submission, submission_id) + + # Mark as downloaded + submission.downloaded = True + db.session.commit() + + return send_file(current_app.storage.path(source.filesystem_id, + submission.filename), + mimetype="application/pgp-encrypted", + as_attachment=True) @api.route('/sources//submissions//', methods=['GET', 'DELETE']) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index add6123167..cb44beb6e5 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -315,3 +315,25 @@ def test_authorized_user_can_delete_source_collection(journalist_app, # Source does not exist assert Source.query.all() == [] + + +def test_authorized_user_can_download_submission(journalist_app, + test_source, + journalist_api_token): + with journalist_app.test_client() as app: + submission_id = test_source['source'].submissions[0].id + source_id = test_source['source'].id + + response = app.get(url_for('api.download_submission', + source_id=source_id, + submission_id=submission_id), + headers=get_api_headers(journalist_api_token)) + + assert response.status_code == 200 + + # Submission should now be marked as downloaded in the database + submission = Submission.query.get(submission_id) + assert submission.downloaded + + # Response should be a PGP encrypted download + assert response.mimetype == 'application/pgp-encrypted' From 4fc88f7d6e93b979d2f83fe72ced51dcf17d5b2a Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Sat, 23 Jun 2018 02:26:29 -0700 Subject: [PATCH 25/71] Journalist API: Add /user endpoint for user information --- securedrop/journalist_app/api.py | 8 ++++++++ securedrop/models.py | 8 ++++++++ securedrop/tests/test_journalist_api.py | 16 +++++++++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 9b3546aa8b..a21f9cbbfe 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -143,4 +143,12 @@ def get_all_submissions(): return jsonify({'submissions': [submission.to_json() for \ submission in submissions]}), 200 + @api.route('/user/', methods=['GET']) + @token_required + def get_current_user(): + # Get current user from token + auth_token = request.headers.get('Authorization').split(" ")[1] + user = Journalist.verify_api_token(auth_token) + return jsonify(user.to_json()), 200 + return api diff --git a/securedrop/models.py b/securedrop/models.py index 7ea4d4db28..0a7c76e264 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -488,6 +488,14 @@ def verify_api_token(token): return None return Journalist.query.get(data['id']) + def to_json(self): + json_user = { + 'username': self.username, + 'last_login': self.last_access, + 'is_admin': self.is_admin + } + return json_user + class JournalistLoginAttempt(db.Model): diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index cb44beb6e5..a6723a66ea 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -102,7 +102,8 @@ def test_user_without_token_cannot_get_protected_endpoints(journalist_app, url_for('api.download_submission', source_id=test_source['source'].id, submission_id=test_source['submissions'][0].id), - url_for('api.get_all_submissions') + url_for('api.get_all_submissions'), + url_for('api.get_current_user') ] with journalist_app.test_client() as app: @@ -337,3 +338,16 @@ def test_authorized_user_can_download_submission(journalist_app, # Response should be a PGP encrypted download assert response.mimetype == 'application/pgp-encrypted' + +def test_authorized_user_can_get_current_user_endpoint(journalist_app, + test_source, + test_journo, + journalist_api_token): + with journalist_app.test_client() as app: + response = app.get(url_for('api.get_current_user'), + headers=get_api_headers(journalist_api_token)) + assert response.status_code == 200 + + json_response = json.loads(response.data) + assert json_response['is_admin'] is False + assert json_response['username'] == test_journo['username'] From 244ff822f7914cb0b2d0175a1ed193bb24357725 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Sat, 23 Jun 2018 21:02:19 -0700 Subject: [PATCH 26/71] Journalist API: Rework token_required, add unit tests request.headers.get doesn't produce KeyErrors, so we should instead try to get the key directly, and then handle the KeyError. Unit tests are added to cover each possible case in the token_required function. --- securedrop/journalist_app/api.py | 4 ++-- securedrop/tests/test_journalist_api.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index a21f9cbbfe..6a8a5c0bf3 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -13,8 +13,8 @@ def token_required(f): @wraps(f) def decorated_function(*args, **kwargs): try: - auth_header = request.headers.get('Authorization') - except: + auth_header = request.headers['Authorization'] + except KeyError: return abort(403, 'API token not found in Authorization header.') if auth_header: diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index a6723a66ea..74e921700d 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -339,6 +339,7 @@ def test_authorized_user_can_download_submission(journalist_app, # Response should be a PGP encrypted download assert response.mimetype == 'application/pgp-encrypted' + def test_authorized_user_can_get_current_user_endpoint(journalist_app, test_source, test_journo, @@ -351,3 +352,24 @@ def test_authorized_user_can_get_current_user_endpoint(journalist_app, json_response = json.loads(response.data) assert json_response['is_admin'] is False assert json_response['username'] == test_journo['username'] + + +def test_request_with_missing_auth_header_triggers_403(journalist_app): + with journalist_app.test_client() as app: + response = app.get(url_for('api.get_current_user'), + headers={ + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }) + assert response.status_code == 403 + + +def test_request_with_auth_header_but_no_token_triggers_403(journalist_app): + with journalist_app.test_client() as app: + response = app.get(url_for('api.get_current_user'), + headers={ + 'Authorization': '', + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }) + assert response.status_code == 403 From 4f93e8f9d77d904522b4aff1982ee163fc2b2615 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Sun, 24 Jun 2018 00:06:14 -0700 Subject: [PATCH 27/71] Journalist API: Add pre-encrypted source reply endpoint --- securedrop/journalist_app/api.py | 30 +++++++++-- securedrop/store.py | 11 ++++ securedrop/tests/test_journalist_api.py | 69 ++++++++++++++++++++++++- 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 6a8a5c0bf3..a6b1525180 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -3,10 +3,10 @@ from flask import abort, Blueprint, current_app, jsonify, request, send_file -import config +from sdconfig import config from db import db from journalist_app import utils -from models import Journalist, Source, Submission +from models import Journalist, Reply, Source, Submission def token_required(f): @@ -134,7 +134,31 @@ def single_submission(source_id, submission_id): @api.route('/sources//reply/', methods=['POST']) @token_required def post_reply(source_id): - pass + source = get_or_404(Source, source_id) + if 'reply' not in request.json: + abort(400) + + # Get current user + auth_token = request.headers.get('Authorization').split(" ")[1] + user = Journalist.verify_api_token(auth_token) + + data = json.loads(request.data) + if not data['reply']: # Reply should not be empty + abort(400) + + source.interaction_count += 1 + filename = current_app.storage.save_pre_encrypted_reply( + source.filesystem_id, + source.interaction_count, + source.journalist_filename, + data['reply']) + + reply = Reply(user, source, + current_app.storage.path(source.filesystem_id, filename)) + db.session.add(reply) + db.session.add(source) + db.session.commit() + return jsonify({'message': 'Your reply has been stored'}), 201 @api.route('/submissions/', methods=['GET']) @token_required diff --git a/securedrop/store.py b/securedrop/store.py index 285c48e710..2329784b7f 100644 --- a/securedrop/store.py +++ b/securedrop/store.py @@ -145,6 +145,17 @@ def save_file_submission(self, filesystem_id, count, journalist_filename, return encrypted_file_name + def save_pre_encrypted_reply(self, filesystem_id, count, + journalist_filename, encrypted_content): + encrypted_file_name = "{0}-{1}-reply.gpg".format(count, + journalist_filename) + encrypted_file_path = self.path(filesystem_id, encrypted_file_name) + + with open(encrypted_file_path, 'wb') as fh: + fh.write(encrypted_content) + + return encrypted_file_path + def save_message_submission(self, filesystem_id, count, journalist_filename, message): filename = "{0}-{1}-msg.gpg".format(count, journalist_filename) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 74e921700d..a0f9e9c892 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -5,9 +5,9 @@ from pyotp import TOTP -from flask import url_for +from flask import current_app, url_for -from models import Journalist, Source, SourceStar, Submission +from models import Journalist, Reply, Source, SourceStar, Submission os.environ['SECUREDROP_ENV'] = 'test' # noqa from sdconfig import SDConfig, config @@ -373,3 +373,68 @@ def test_request_with_auth_header_but_no_token_triggers_403(journalist_app): 'Content-Type': 'application/json' }) assert response.status_code == 403 + + +def test_authorized_user_can_add_reply(journalist_app, journalist_api_token, + test_source, test_journo): + with journalist_app.test_client() as app: + source_id = test_source['source'].id + reply_content = 'This is a plaintext reply' + response = app.post(url_for('api.post_reply', + source_id=source_id), + data=json.dumps({'reply': reply_content}), + headers=get_api_headers(journalist_api_token)) + assert response.status_code == 201 + + with journalist_app.app_context(): # Now verify everything was saved. + # Get most recent reply in the database + reply = Reply.query.order_by(Reply.id.desc()).first() + + assert reply.journalist_id == test_journo['id'] + assert reply.source_id == source_id + + source = Source.query.get(source_id) + + expected_filename = '{}-{}-reply.gpg'.format( + source.interaction_count, source.journalist_filename) + + expected_filepath = current_app.storage.path( + source.filesystem_id, expected_filename) + + with open(expected_filepath, 'rb') as fh: + saved_content = fh.read() + + assert reply_content == saved_content + + +def test_reply_without_content_400(journalist_app, journalist_api_token, + test_source, test_journo): + with journalist_app.test_client() as app: + source_id = test_source['source'].id + response = app.post(url_for('api.post_reply', + source_id=source_id), + data=json.dumps({'reply': ''}), + headers=get_api_headers(journalist_api_token)) + assert response.status_code == 400 + + +def test_reply_without_reply_field_400(journalist_app, journalist_api_token, + test_source, test_journo): + with journalist_app.test_client() as app: + source_id = test_source['source'].id + response = app.post(url_for('api.post_reply', + source_id=source_id), + data=json.dumps({'other': 'stuff'}), + headers=get_api_headers(journalist_api_token)) + assert response.status_code == 400 + + +def test_reply_without_json_400(journalist_app, journalist_api_token, + test_source, test_journo): + with journalist_app.test_client() as app: + source_id = test_source['source'].id + response = app.post(url_for('api.post_reply', + source_id=source_id), + data='invalid', + headers=get_api_headers(journalist_api_token)) + assert response.status_code == 400 From 6d069a871b77ea86b900999779456377d3eb4ae0 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Sun, 24 Jun 2018 01:09:13 -0700 Subject: [PATCH 28/71] Journalist API: Reject plaintext replies server-side There is a function implemented in crypto_util.py here for reliably detecting GPG ciphertext, but it's pretty slow --- securedrop/crypto_util.py | 3 +++ securedrop/journalist_app/api.py | 15 ++++++++++----- securedrop/store.py | 16 ++++++++++++++-- securedrop/tests/test_journalist_api.py | 21 ++++++++++++++++++++- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/securedrop/crypto_util.py b/securedrop/crypto_util.py index a0793a8747..d2ae1a66b2 100644 --- a/securedrop/crypto_util.py +++ b/securedrop/crypto_util.py @@ -193,6 +193,9 @@ def getkey(self, name): return key['fingerprint'] return None + def is_encrypted(self, content): + return bool(self.gpg.list_packets(content).key) + def encrypt(self, plaintext, fingerprints, output=None): # Verify the output path if output: diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index a6b1525180..9d5d78bedd 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -7,6 +7,7 @@ from db import db from journalist_app import utils from models import Journalist, Reply, Source, Submission +from store import NotEncrypted def token_required(f): @@ -147,11 +148,15 @@ def post_reply(source_id): abort(400) source.interaction_count += 1 - filename = current_app.storage.save_pre_encrypted_reply( - source.filesystem_id, - source.interaction_count, - source.journalist_filename, - data['reply']) + try: + filename = current_app.storage.save_pre_encrypted_reply( + source.filesystem_id, + source.interaction_count, + source.journalist_filename, + data['reply']) + except NotEncrypted: + return jsonify( + {'message': 'You must encrypt replies client side'}), 412 reply = Reply(user, source, current_app.storage.path(source.filesystem_id, filename)) diff --git a/securedrop/store.py b/securedrop/store.py index 2329784b7f..142090744f 100644 --- a/securedrop/store.py +++ b/securedrop/store.py @@ -24,6 +24,13 @@ class PathException(Exception): pass +class NotEncrypted(Exception): + """An exception raised if a file expected to be encrypted client-side + is actually plaintext. + """ + pass + + class Storage: def __init__(self, storage_path, temp_dir, gpg_key): @@ -146,13 +153,18 @@ def save_file_submission(self, filesystem_id, count, journalist_filename, return encrypted_file_name def save_pre_encrypted_reply(self, filesystem_id, count, - journalist_filename, encrypted_content): + journalist_filename, content): + + # if not current_app.crypto_util.is_encrypted(content): # slow + if 'BEGIN PGP MESSAGE' not in content: + raise NotEncrypted + encrypted_file_name = "{0}-{1}-reply.gpg".format(count, journalist_filename) encrypted_file_path = self.path(filesystem_id, encrypted_file_name) with open(encrypted_file_path, 'wb') as fh: - fh.write(encrypted_content) + fh.write(content) return encrypted_file_path diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index a0f9e9c892..686f380f46 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -375,11 +375,30 @@ def test_request_with_auth_header_but_no_token_triggers_403(journalist_app): assert response.status_code == 403 +def test_unencrypted_replies_get_rejected(journalist_app, journalist_api_token, + test_source, test_journo): + with journalist_app.test_client() as app: + source_id = test_source['source'].id + reply_content = 'This is a plaintext reply' + response = app.post(url_for('api.post_reply', + source_id=source_id), + data=json.dumps({'reply': reply_content}), + headers=get_api_headers(journalist_api_token)) + assert response.status_code == 412 + + def test_authorized_user_can_add_reply(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: source_id = test_source['source'].id - reply_content = 'This is a plaintext reply' + + # First we must encrypt the reply, or it will get rejected + # by the server. + source_key = current_app.crypto_util.getkey( + test_source['source'].filesystem_id) + reply_content = current_app.crypto_util.gpg.encrypt( + 'This is a plaintext reply', source_key).data + response = app.post(url_for('api.post_reply', source_id=source_id), data=json.dumps({'reply': reply_content}), From 7adb2544851f36a689a16e3bc63f6358ef05e466 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Sun, 24 Jun 2018 01:56:47 -0700 Subject: [PATCH 29/71] Journalist API: API should provide armored pubkey of source --- securedrop/crypto_util.py | 4 ++++ securedrop/models.py | 5 +++++ securedrop/tests/test_journalist_api.py | 1 + 3 files changed, 10 insertions(+) diff --git a/securedrop/crypto_util.py b/securedrop/crypto_util.py index d2ae1a66b2..739f4fe30e 100644 --- a/securedrop/crypto_util.py +++ b/securedrop/crypto_util.py @@ -193,6 +193,10 @@ def getkey(self, name): return key['fingerprint'] return None + def export_pubkey(self, name): + fingerprint = self.getkey(name) + return self.gpg.export_keys(fingerprint) + def is_encrypted(self, content): return bool(self.gpg.list_packets(content).key) diff --git a/securedrop/models.py b/securedrop/models.py index 0a7c76e264..8077742b55 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -103,6 +103,10 @@ def collection(self): collection.sort(key=lambda x: int(x.filename.split('-')[0])) return collection + @property + def public_key(self): + return current_app.crypto_util.export_pubkey(self.filesystem_id) + def to_json(self): docs_msg_count = self.documents_messages_count() @@ -113,6 +117,7 @@ def to_json(self): 'flagged': self.flagged, 'last_updated': self.last_updated, 'interaction_count': self.interaction_count, + 'public_key': self.public_key, 'number_of_documents': docs_msg_count['documents'], 'number_of_messages': docs_msg_count['messages'], 'submissions_url': url_for('api.all_source_submissions', diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 686f380f46..544cf35d36 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -171,6 +171,7 @@ def test_authorized_user_gets_single_source(journalist_app, test_source, data = json.loads(response.data) assert data['source_id'] == test_source['source'].id + assert 'BEGIN PGP PUBLIC KEY' in data['public_key'] def test_get_non_existant_source_404s(journalist_app, journalist_api_token): From 459645273cf766893d02f8089b5a05b09e1f573a Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Sun, 24 Jun 2018 02:01:03 -0700 Subject: [PATCH 30/71] Journalist interface: Add 400 error handler --- securedrop/journalist_app/__init__.py | 9 +++++++++ securedrop/journalist_templates/400.html | 4 ++++ 2 files changed, 13 insertions(+) create mode 100644 securedrop/journalist_templates/400.html diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index 7ec11b7061..06a1b8ec55 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -140,6 +140,15 @@ def setup_g(): app.register_blueprint(col.make_blueprint(config), url_prefix='/col') app.register_blueprint(api.make_blueprint(config), url_prefix='/api/v1') + @app.errorhandler(400) + def bad_request(message): + if request.headers['Content-Type'] == 'application/json': + response = jsonify({'error': 'bad request', + 'message': 'We could not understand'}) + return response, 400 + else: + return render_template('400.html'), 400 + @app.errorhandler(403) def forbidden(message): if request.headers['Content-Type'] == 'application/json': diff --git a/securedrop/journalist_templates/400.html b/securedrop/journalist_templates/400.html new file mode 100644 index 0000000000..2f509e0bad --- /dev/null +++ b/securedrop/journalist_templates/400.html @@ -0,0 +1,4 @@ +{% extends "base.html" %} +{% block body %} +

Bad Request

+{% endblock %} From d6765c849b687ac4e3c609fa17ea4df3c9fa366b Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Sun, 24 Jun 2018 04:04:36 -0700 Subject: [PATCH 31/71] Journalist API: cleanup and use same slug as journalist interface The journalist interface is using the filesystem ID as a slug (/col/), this commit makes that switch. Linting issues are also resolved in this commit --- securedrop/journalist_app/__init__.py | 2 +- securedrop/journalist_app/api.py | 60 +++++++------- securedrop/models.py | 21 +++-- securedrop/tests/test_journalist_api.py | 100 +++++++++++++----------- 4 files changed, 99 insertions(+), 84 deletions(-) diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index 06a1b8ec55..ded1bc6ce8 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from datetime import datetime, timedelta -from flask import (abort, Flask, session, redirect, url_for, flash, g, request, +from flask import (Flask, session, redirect, url_for, flash, g, request, jsonify, render_template) from flask_assets import Environment from flask_babel import gettext diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 9d5d78bedd..3ce9a23ce9 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -3,7 +3,6 @@ from flask import abort, Blueprint, current_app, jsonify, request, send_file -from sdconfig import config from db import db from journalist_app import utils from models import Journalist, Reply, Source, Submission @@ -28,8 +27,11 @@ def decorated_function(*args, **kwargs): return decorated_function -def get_or_404(model, object_id): - result = model.query.get(object_id) +def get_or_404(model, object_id, column=''): + if column: + result = model.query.filter(column == object_id).first() + else: + result = model.query.get(object_id) if result is None: abort(404) return result @@ -56,7 +58,7 @@ def get_token(): journalist = Journalist.login(username, password, one_time_code) return jsonify({'token': journalist.generate_api_token( expiration=1800), 'expiration': 1800}), 200 - except: + except Exception: return abort(403, 'Token authentication failed.') @api.route('/sources/', methods=['GET']) @@ -66,47 +68,47 @@ def get_all_sources(): return jsonify( {'sources': [source.to_json() for source in sources]}), 200 - @api.route('/sources//', methods=['GET']) + @api.route('/sources//', methods=['GET']) @token_required - def single_source(source_id): - source = get_or_404(Source, source_id) + def single_source(filesystem_id): + source = get_or_404(Source, filesystem_id, Source.filesystem_id) return jsonify(source.to_json()), 200 - @api.route('/sources//add_star/', methods=['POST']) + @api.route('/sources//add_star/', methods=['POST']) @token_required - def add_star(source_id): - source = get_or_404(Source, source_id) + def add_star(filesystem_id): + source = get_or_404(Source, filesystem_id, Source.filesystem_id) utils.make_star_true(source.filesystem_id) db.session.commit() return jsonify({'message': 'Star added'}), 201 - @api.route('/sources//remove_star/', methods=['DELETE']) + @api.route('/sources//remove_star/', methods=['DELETE']) @token_required - def remove_star(source_id): - source = get_or_404(Source, source_id) + def remove_star(filesystem_id): + source = get_or_404(Source, filesystem_id, Source.filesystem_id) utils.make_star_false(source.filesystem_id) db.session.commit() return jsonify({'message': 'Star removed'}), 200 - @api.route('/sources//submissions/', methods=['GET', + @api.route('/sources//submissions/', methods=['GET', 'DELETE']) @token_required - def all_source_submissions(source_id): + def all_source_submissions(filesystem_id): if request.method == 'GET': - source = get_or_404(Source, source_id) + source = get_or_404(Source, filesystem_id, Source.filesystem_id) return jsonify( - {'submissions': [submission.to_json() for \ + {'submissions': [submission.to_json() for submission in source.submissions]}), 200 elif request.method == 'DELETE': - source = get_or_404(Source, source_id) + source = get_or_404(Source, filesystem_id, Source.filesystem_id) utils.delete_collection(source.filesystem_id) return jsonify({'message': 'Source and submissions deleted'}), 200 - @api.route('/sources//submissions//download/', # noqa + @api.route('/sources//submissions//download/', # noqa methods=['GET']) @token_required - def download_submission(source_id, submission_id): - source = get_or_404(Source, source_id) + def download_submission(filesystem_id, submission_id): + source = get_or_404(Source, filesystem_id, Source.filesystem_id) submission = get_or_404(Submission, submission_id) # Mark as downloaded @@ -118,24 +120,24 @@ def download_submission(source_id, submission_id): mimetype="application/pgp-encrypted", as_attachment=True) - @api.route('/sources//submissions//', + @api.route('/sources//submissions//', methods=['GET', 'DELETE']) @token_required - def single_submission(source_id, submission_id): + def single_submission(filesystem_id, submission_id): if request.method == 'GET': submission = get_or_404(Submission, submission_id) return jsonify(submission.to_json()), 200 elif request.method == 'DELETE': submission = get_or_404(Submission, submission_id) - source = get_or_404(Source, source_id) + source = get_or_404(Source, filesystem_id, Source.filesystem_id) utils.delete_file(source.filesystem_id, submission.filename, - submission) + submission) return jsonify({'message': 'Submission deleted'}), 200 - @api.route('/sources//reply/', methods=['POST']) + @api.route('/sources//reply/', methods=['POST']) @token_required - def post_reply(source_id): - source = get_or_404(Source, source_id) + def post_reply(filesystem_id): + source = get_or_404(Source, filesystem_id, Source.filesystem_id) if 'reply' not in request.json: abort(400) @@ -169,7 +171,7 @@ def post_reply(source_id): @token_required def get_all_submissions(): submissions = Submission.query.all() - return jsonify({'submissions': [submission.to_json() for \ + return jsonify({'submissions': [submission.to_json() for submission in submissions]}), 200 @api.route('/user/', methods=['GET']) diff --git a/securedrop/models.py b/securedrop/models.py index 8077742b55..d259357c1a 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -111,8 +111,10 @@ def to_json(self): docs_msg_count = self.documents_messages_count() json_source = { - 'url': url_for('api.single_source', source_id=self.id), + 'url': url_for('api.single_source', + filesystem_id=self.filesystem_id), 'source_id': self.id, + 'filesystem_id': self.filesystem_id, 'journalist_designation': self.journalist_designation, 'flagged': self.flagged, 'last_updated': self.last_updated, @@ -121,10 +123,13 @@ def to_json(self): 'number_of_documents': docs_msg_count['documents'], 'number_of_messages': docs_msg_count['messages'], 'submissions_url': url_for('api.all_source_submissions', - source_id=self.id), - 'add_star_url': url_for('api.add_star', source_id=self.id), - 'remove_star_url': url_for('api.remove_star', source_id=self.id), - 'reply_url': url_for('api.post_reply', source_id=self.id) + filesystem_id=self.filesystem_id), + 'add_star_url': url_for('api.add_star', + filesystem_id=self.filesystem_id), + 'remove_star_url': url_for('api.remove_star', + filesystem_id=self.filesystem_id), + 'reply_url': url_for('api.post_reply', + filesystem_id=self.filesystem_id) } return json_source @@ -154,16 +159,16 @@ def __repr__(self): def to_json(self): json_submission = { 'source_url': url_for('api.single_source', - source_id=self.source_id), + filesystem_id=self.source.filesystem_id), 'submission_url': url_for('api.single_submission', - source_id=self.source_id, + filesystem_id=self.source.filesystem_id, submission_id=self.id), 'submission_id': self.id, 'filename': self.filename, 'size': self.size, 'is_read': self.downloaded, 'download_url': url_for('api.download_submission', - source_id=self.source_id, + filesystem_id=self.source.filesystem_id, submission_id=self.id), } return json_submission diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 544cf35d36..d2b5f24dc3 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- import json import os -import pytest from pyotp import TOTP @@ -10,7 +9,6 @@ from models import Journalist, Reply, Source, SourceStar, Submission os.environ['SECUREDROP_ENV'] = 'test' # noqa -from sdconfig import SDConfig, config from utils.api_helper import get_api_headers @@ -37,8 +35,8 @@ def test_valid_user_can_get_an_api_token(journalist_app, test_journo): headers=get_api_headers()) observed_response = json.loads(response.data) - assert isinstance(Journalist.verify_api_token(observed_response['token']), - Journalist) is True + assert isinstance(Journalist.verify_api_token( + observed_response['token']), Journalist) is True assert response.status_code == 200 @@ -91,16 +89,17 @@ def test_authorized_user_gets_all_sources(journalist_app, test_source, def test_user_without_token_cannot_get_protected_endpoints(journalist_app, test_source): with journalist_app.app_context(): + filesystem_id = test_source['source'].filesystem_id protected_routes = [ url_for('api.get_all_sources'), - url_for('api.single_source', source_id=test_source['source'].id), + url_for('api.single_source', filesystem_id=filesystem_id), url_for('api.all_source_submissions', - source_id=test_source['source'].id), + filesystem_id=filesystem_id), url_for('api.single_submission', - source_id=test_source['source'].id, + filesystem_id=filesystem_id, submission_id=test_source['submissions'][0].id), url_for('api.download_submission', - source_id=test_source['source'].id, + filesystem_id=filesystem_id, submission_id=test_source['submissions'][0].id), url_for('api.get_all_submissions'), url_for('api.get_current_user') @@ -117,20 +116,21 @@ def test_user_without_token_cannot_get_protected_endpoints(journalist_app, def test_user_without_token_cannot_delete_protected_endpoints(journalist_app, test_source): with journalist_app.app_context(): + filesystem_id = test_source['source'].filesystem_id protected_routes = [ url_for('api.all_source_submissions', - source_id=test_source['source'].id), + filesystem_id=filesystem_id), url_for('api.single_submission', - source_id=test_source['source'].id, + filesystem_id=filesystem_id, submission_id=test_source['submissions'][0].id), url_for('api.remove_star', - source_id=test_source['source'].id), + filesystem_id=filesystem_id), ] with journalist_app.test_client() as app: for protected_route in protected_routes: response = app.delete(protected_route, - headers=get_api_headers('')) + headers=get_api_headers('')) assert response.status_code == 403 @@ -138,9 +138,10 @@ def test_user_without_token_cannot_delete_protected_endpoints(journalist_app, def test_user_without_token_cannot_post_protected_endpoints(journalist_app, test_source): with journalist_app.app_context(): + filesystem_id = test_source['source'].filesystem_id protected_routes = [ - url_for('api.post_reply', source_id=test_source['source'].id), - url_for('api.add_star', source_id=test_source['source'].id) + url_for('api.post_reply', filesystem_id=filesystem_id), + url_for('api.add_star', filesystem_id=filesystem_id) ] with journalist_app.test_client() as app: @@ -163,8 +164,9 @@ def test_api_404(journalist_app, journalist_api_token): def test_authorized_user_gets_single_source(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: + filesystem_id = test_source['source'].filesystem_id response = app.get(url_for('api.single_source', - source_id=test_source['source'].id), + filesystem_id=filesystem_id), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 @@ -177,7 +179,7 @@ def test_authorized_user_gets_single_source(journalist_app, test_source, def test_get_non_existant_source_404s(journalist_app, journalist_api_token): with journalist_app.test_client() as app: response = app.get(url_for('api.single_source', - source_id=1), + filesystem_id=1), headers=get_api_headers(journalist_api_token)) assert response.status_code == 404 @@ -186,8 +188,10 @@ def test_get_non_existant_source_404s(journalist_app, journalist_api_token): def test_authorized_user_can_star_a_source(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: + filesystem_id = test_source['source'].filesystem_id source_id = test_source['source'].id - response = app.post(url_for('api.add_star', source_id=source_id), + response = app.post(url_for('api.add_star', + filesystem_id=filesystem_id), headers=get_api_headers(journalist_api_token)) assert response.status_code == 201 @@ -200,12 +204,15 @@ def test_authorized_user_can_star_a_source(journalist_app, test_source, def test_authorized_user_can_unstar_a_source(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: + filesystem_id = test_source['source'].filesystem_id source_id = test_source['source'].id - response = app.post(url_for('api.add_star', source_id=source_id), + response = app.post(url_for('api.add_star', + filesystem_id=filesystem_id), headers=get_api_headers(journalist_api_token)) assert response.status_code == 201 - response = app.delete(url_for('api.remove_star', source_id=source_id), + response = app.delete(url_for('api.remove_star', + filesystem_id=filesystem_id), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 @@ -217,8 +224,9 @@ def test_authorized_user_can_unstar_a_source(journalist_app, test_source, def test_disallowed_methods_produces_405(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: - source_id = test_source['source'].id - response = app.delete(url_for('api.add_star', source_id=source_id), + filesystem_id = test_source['source'].filesystem_id + response = app.delete(url_for('api.add_star', + filesystem_id=filesystem_id), headers=get_api_headers(journalist_api_token)) json_response = json.loads(response.data) @@ -235,10 +243,10 @@ def test_authorized_user_can_get_all_submissions(journalist_app, test_source, json_response = json.loads(response.data) - observed_submissions = [submission['filename'] for \ + observed_submissions = [submission['filename'] for submission in json_response['submissions']] - expected_submissions = [submission.filename for \ + expected_submissions = [submission.filename for submission in Submission.query.all()] assert observed_submissions == expected_submissions @@ -246,18 +254,18 @@ def test_authorized_user_can_get_all_submissions(journalist_app, test_source, def test_authorized_user_get_source_submissions(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: - source_id = test_source['source'].id + filesystem_id = test_source['source'].filesystem_id response = app.get(url_for('api.all_source_submissions', - source_id=source_id), + filesystem_id=filesystem_id), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 json_response = json.loads(response.data) - observed_submissions = [submission['filename'] for \ + observed_submissions = [submission['filename'] for submission in json_response['submissions']] - expected_submissions = [submission.filename for submission in \ + expected_submissions = [submission.filename for submission in test_source['source'].submissions] assert observed_submissions == expected_submissions @@ -267,9 +275,9 @@ def test_authorized_user_can_get_single_submission(journalist_app, journalist_api_token): with journalist_app.test_client() as app: submission_id = test_source['source'].submissions[0].id - source_id = test_source['source'].id + filesystem_id = test_source['source'].filesystem_id response = app.get(url_for('api.single_submission', - source_id=source_id, + filesystem_id=filesystem_id, submission_id=submission_id), headers=get_api_headers(journalist_api_token)) @@ -290,9 +298,9 @@ def test_authorized_user_can_delete_single_submission(journalist_app, journalist_api_token): with journalist_app.test_client() as app: submission_id = test_source['source'].submissions[0].id - source_id = test_source['source'].id + filesystem_id = test_source['source'].filesystem_id response = app.delete(url_for('api.single_submission', - source_id=source_id, + filesystem_id=filesystem_id, submission_id=submission_id), headers=get_api_headers(journalist_api_token)) @@ -307,10 +315,9 @@ def test_authorized_user_can_delete_source_collection(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: - submission_id = test_source['source'].submissions[0].id - source_id = test_source['source'].id + filesystem_id = test_source['source'].filesystem_id response = app.delete(url_for('api.all_source_submissions', - source_id=source_id), + filesystem_id=filesystem_id), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 @@ -324,10 +331,10 @@ def test_authorized_user_can_download_submission(journalist_app, journalist_api_token): with journalist_app.test_client() as app: submission_id = test_source['source'].submissions[0].id - source_id = test_source['source'].id + filesystem_id = test_source['source'].filesystem_id response = app.get(url_for('api.download_submission', - source_id=source_id, + filesystem_id=filesystem_id, submission_id=submission_id), headers=get_api_headers(journalist_api_token)) @@ -379,10 +386,10 @@ def test_request_with_auth_header_but_no_token_triggers_403(journalist_app): def test_unencrypted_replies_get_rejected(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: - source_id = test_source['source'].id + filesystem_id = test_source['source'].filesystem_id reply_content = 'This is a plaintext reply' response = app.post(url_for('api.post_reply', - source_id=source_id), + filesystem_id=filesystem_id), data=json.dumps({'reply': reply_content}), headers=get_api_headers(journalist_api_token)) assert response.status_code == 412 @@ -392,6 +399,7 @@ def test_authorized_user_can_add_reply(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: source_id = test_source['source'].id + filesystem_id = test_source['source'].filesystem_id # First we must encrypt the reply, or it will get rejected # by the server. @@ -401,7 +409,7 @@ def test_authorized_user_can_add_reply(journalist_app, journalist_api_token, 'This is a plaintext reply', source_key).data response = app.post(url_for('api.post_reply', - source_id=source_id), + filesystem_id=filesystem_id), data=json.dumps({'reply': reply_content}), headers=get_api_headers(journalist_api_token)) assert response.status_code == 201 @@ -430,9 +438,9 @@ def test_authorized_user_can_add_reply(journalist_app, journalist_api_token, def test_reply_without_content_400(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: - source_id = test_source['source'].id + filesystem_id = test_source['source'].filesystem_id response = app.post(url_for('api.post_reply', - source_id=source_id), + filesystem_id=filesystem_id), data=json.dumps({'reply': ''}), headers=get_api_headers(journalist_api_token)) assert response.status_code == 400 @@ -441,20 +449,20 @@ def test_reply_without_content_400(journalist_app, journalist_api_token, def test_reply_without_reply_field_400(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: - source_id = test_source['source'].id + filesystem_id = test_source['source'].filesystem_id response = app.post(url_for('api.post_reply', - source_id=source_id), + filesystem_id=filesystem_id), data=json.dumps({'other': 'stuff'}), headers=get_api_headers(journalist_api_token)) assert response.status_code == 400 def test_reply_without_json_400(journalist_app, journalist_api_token, - test_source, test_journo): + test_source, test_journo): with journalist_app.test_client() as app: - source_id = test_source['source'].id + filesystem_id = test_source['source'].filesystem_id response = app.post(url_for('api.post_reply', - source_id=source_id), + filesystem_id=filesystem_id), data='invalid', headers=get_api_headers(journalist_api_token)) assert response.status_code == 400 From 024ff356b2f02412dbf13a312afcbb3367ab09b9 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Thu, 28 Jun 2018 18:25:44 -0700 Subject: [PATCH 32/71] Journalist API: Refactor error handling This commit makes several changes to error handling on the journalist interface. First, CSRF protection is not necessary for a REST API that is not using cookies for authentication, which this one is not (we pass a token in a HTTP header). We exempt only the api blueprint views from CSRF protection, and leave it as is for the remainder of the journalist application. Second, we use blueprint-level handlers so that we can more easily present the relevant response to the user: an informative JSON error response (i.e. one that contains details of why the request was not valid) or an HTML page that is rendered in the style of the rest of the journalist application. Note that blueprint level 404 and 405 error handlers are not respected, so we add logic to use the API's error handlers to return nice error messages to the user. --- securedrop/journalist_app/__init__.py | 49 +++++++++++-------------- securedrop/journalist_app/api.py | 30 +++++++++++++-- securedrop/tests/test_journalist_api.py | 3 +- 3 files changed, 49 insertions(+), 33 deletions(-) diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index ded1bc6ce8..c8a886d039 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta from flask import (Flask, session, redirect, url_for, flash, g, request, - jsonify, render_template) + render_template) from flask_assets import Environment from flask_babel import gettext from flask_wtf.csrf import CSRFProtect, CSRFError @@ -41,7 +41,7 @@ def create_app(config): app.config.from_object(config.JournalistInterfaceFlaskConfig) app.sdconfig = config - CSRFProtect(app) + csrf = CSRFProtect(app) Environment(app) if config.DATABASE_ENGINE == "sqlite": @@ -138,42 +138,35 @@ def setup_g(): url_prefix='/account') app.register_blueprint(admin.make_blueprint(config), url_prefix='/admin') app.register_blueprint(col.make_blueprint(config), url_prefix='/col') - app.register_blueprint(api.make_blueprint(config), url_prefix='/api/v1') + api_blueprint = api.make_blueprint(config) + app.register_blueprint(api_blueprint, url_prefix='/api/v1') + csrf.exempt(api_blueprint) @app.errorhandler(400) def bad_request(message): - if request.headers['Content-Type'] == 'application/json': - response = jsonify({'error': 'bad request', - 'message': 'We could not understand'}) - return response, 400 - else: - return render_template('400.html'), 400 + return render_template('400.html'), 400 @app.errorhandler(403) def forbidden(message): - if request.headers['Content-Type'] == 'application/json': - response = jsonify({'error': 'forbidden', - 'message': 'Not authorized'}) - return response, 403 - else: - return render_template('403.html'), 403 + return render_template('403.html'), 403 @app.errorhandler(404) - def not_found(message): - if request.headers['Content-Type'] == 'application/json': - response = jsonify({'error': 'not found', - 'message': 'we could not find that resource'}) - return response, 404 - else: - return render_template('404.html'), 404 + def handle_404(message): + # Workaround for no blueprint-level 404/5 error handlers, see: + # https://github.com/pallets/flask/issues/503#issuecomment-71383286 + if request.path.startswith('/api/'): + handler = app.error_handler_spec['api'][404].values()[0] + return handler(message) + + return render_template('404.html'), 404 @app.errorhandler(405) def method_not_allowed(message): - if request.headers['Content-Type'] == 'application/json': - response = jsonify({'error': 'method not allowed', - 'message': 'Not allowed'}) - return response, 405 - else: - return render_template('405.html'), 405 + # blueprint level 405 error handlers also need to be applied manually + if request.path.startswith('/api/'): + handler = app.error_handler_spec['api'][405].values()[0] + return handler(message) + + return render_template('405.html'), 405 return app diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 3ce9a23ce9..d79a00fff2 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -139,15 +139,15 @@ def single_submission(filesystem_id, submission_id): def post_reply(filesystem_id): source = get_or_404(Source, filesystem_id, Source.filesystem_id) if 'reply' not in request.json: - abort(400) + abort(400, 'reply not found in request body') # Get current user auth_token = request.headers.get('Authorization').split(" ")[1] user = Journalist.verify_api_token(auth_token) data = json.loads(request.data) - if not data['reply']: # Reply should not be empty - abort(400) + if not data['reply']: + abort(400, 'reply should not be empty') source.interaction_count += 1 try: @@ -182,4 +182,28 @@ def get_current_user(): user = Journalist.verify_api_token(auth_token) return jsonify(user.to_json()), 200 + @api.errorhandler(403) + def forbidden(message): + response = jsonify({'error': 'forbidden', + 'message': message.description}) + return response, 403 + + @api.errorhandler(400) + def bad_request(message): + response = jsonify({'error': 'bad request', + 'message': message.description}) + return response, 400 + + @api.errorhandler(404) + def not_found(message): + response = jsonify({'error': 'not found', + 'message': message.description}) + return response, 404 + + @api.errorhandler(405) + def method_not_allowed(message): + response = jsonify({'error': 'method not allowed', + 'message': message.description}) + return response, 405 + return api diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index d2b5f24dc3..ec7e6b89e1 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -14,8 +14,7 @@ def test_unauthenticated_user_gets_all_endpoints(journalist_app): with journalist_app.test_client() as app: - response = app.get(url_for('api.get_endpoints'), - content_type='application/json') + response = app.get(url_for('api.get_endpoints')) observed_endpoints = json.loads(response.data) From 1d432084a2d9e13756630f32f790b4157066128d Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Thu, 28 Jun 2018 18:29:55 -0700 Subject: [PATCH 33/71] Journalist API: Expiration date should be consistent This modifies the expiration date of the authorization token to be consistent across the source interface, journalist interface and journalist API. This is important because the session timeout was set to ensure that up to 500 MB can be downloaded without expiry of the API token. For more details, see the explanation in https://github.com/freedomofpress/securedrop/issues/2503 --- securedrop/journalist_app/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index d79a00fff2..725e6ca104 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -57,7 +57,7 @@ def get_token(): try: journalist = Journalist.login(username, password, one_time_code) return jsonify({'token': journalist.generate_api_token( - expiration=1800), 'expiration': 1800}), 200 + expiration=7200), 'expiration': 7200}), 200 except Exception: return abort(403, 'Token authentication failed.') From 183cc3b6cc0be9afa57de8bc2d3bec6ba3e147d6 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 25 Jun 2018 09:58:04 -0700 Subject: [PATCH 34/71] Journalist API: Developer documentation This documentation is intended for a developer consuming the journalist API. --- docs/development/journalist_api.rst | 412 ++++++++++++++++++++++++++++ docs/index.rst | 1 + 2 files changed, 413 insertions(+) create mode 100644 docs/development/journalist_api.rst diff --git a/docs/development/journalist_api.rst b/docs/development/journalist_api.rst new file mode 100644 index 0000000000..53519e5c55 --- /dev/null +++ b/docs/development/journalist_api.rst @@ -0,0 +1,412 @@ +Journalist Interface API +======================== + +This document describes the endpoints for SecureDrop's Journalist Interface +API. + +Versioning +~~~~~~~~~~ + +The API is versioned and we are currently using version 1. This is set via the +base URL, which is: + +.. code:: sh + + /api/v1/ + +Content Type +~~~~~~~~~~~~ + +Clients shall send the following headers: + +.. code:: sh + + 'Accept': 'application/json', + 'Content-Type': 'application/json' + +Authentication +~~~~~~~~~~~~~~ + +``POST /api/v1/token`` to get a token with the username, password, and 2FA +token in the request body: + +.. code:: sh + + { + "username": "journalist", + "password": "mypasswordgoeshere", + "one_time_code": "123456" + } + +Thereafter in order to authenticate to protected endpoints, send the token in +HTTP Authorization header: + +.. code:: sh + + Authorization: Token yourtokengoeshere + +This header will be checked with each API request to see if it is valid and +not yet expired. Tokens expire after 7200 seconds (120 minutes). + +Endpoints +~~~~~~~~~ + +Root Endpoint +------------- + +Does not require authentication. + +The root endpoint describes the available resources: + +.. code:: sh + + GET /api/v1/ + +Response 200 (application/json): + +.. code:: sh + + { + "current_user_url": "/api/v1/user/", + "sources_url": "/api/v1/sources/", + "submissions_url": "/api/v1/submissions/" + "token_url": "/api/v1/token/" + } + +Sources ``[/sources]`` +---------------------- + +Get all sources [``GET``] +^^^^^^^^^^^^^^^^^^^^^^^^^ + +Requires authentication. Provides a list of all sources and data about them +(such as number of documents, submissions, and their public key that replies +should be encrypted to). + +.. code:: sh + + GET /api/v1/sources/ + +Response 200 (application/json): + +.. code:: sh + + { + "sources": [ + { + "add_star_url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/add_star/", + "filesystem_id": "44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A=", + "flagged": false, + "interaction_count": 2, + "journalist_designation": "olfactory yuppie", + "last_updated": "Fri, 29 Jun 2018 19:11:28 GMT", + "number_of_documents": 0, + "number_of_messages": 2, + "public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFs2hGEBEACX9PSn9146bqup7MD3z4JLC2+m5GtXjOPmHVk7YRPwym7Q1XDx\n1exvXA1b17X6kj7TDPvBv8Gupro9BNAilPja+zB+m2JWKrdTjZYIWzZJ31WIC3Xm\nMs3V2dOZ1fCJlD+r2SiKLVzyODDpAoL42taxHXskhKgZvvUPsZv3abQctUOPtsWG\nKs9acPGGb/NnBVgPpdNzF7bKPpqqIHjMhb3WTEzGl8SYU/mfHx1DELzWmocB4v6s\nV4xMNKopyT44Or/ZeIGJf3SiTTsMSuU8IKfvzQKuCNT9IjWJmnYnYU+Zn/Zx/+q3\n1RWUs5z39e6OTT5qQwpxaharnJyM1u7vWY3R0rcZYkrAWQYgx/Ilf4/W1XSU7qmx\niH43mOupI1vQo0caJZwUvK83es2wmQsTNGJ1wqIU4pQU8nQzrlOuAWT3d/AjTXWh\nfFHMeRwfb2b2kRxp+hgFlC1hwpJG6o1+1kVUFUrh7N7Ln7WZi+UgQ23KGN1bU22D\nmY6fdEnssrODM8ly7AIHYhNOxtw/MnnWNlzt6n7gT26hN9VivXIczVxdkpV/vQz5\nng+olaLfXbf/yF/eTCmlsVdvALpDYYfO2VORcXe3JMTgXFzwQExz4auGdQlzH3ju\nmutOD5d0ETsgYP6lkO9wQrOqoqG/YnX+mUUc2H2wowYi5iFi11sLdbE7LQARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8NDRZR1oyUjc2NDNUWEJBNjVaS1JPNUQ2UUgy\nNlJKN05WREZNUUpWRlNNTTZXQTVXM1pEWE5VWUtHQlRFVVlHRkNBQkJVRURMUTdP\nS1M2NTdXS09HVUhGTFZETFE3NUdXVE9YNEQ0QT0+iQI/BBMBCgApBQJbNoRhAhsv\nBQkB4M5fBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQvPRLirPeGcnf/w//\nXOvsO/N6UtQasiE121xa0AwKtptaRUoprEUP8af3+tQ28Ibo+Io1LLEQDODS4Btu\n7rz2eXjhw6XjvtGYXjbOVtXVHqynPZu2eW+er5cbi+zlSjnN7RyLndsg5PZ457q0\n5b1p4olGEPVTFhjKmFoWcYGmfW2q/QvqD9uz4BQWpevMwpop0k7dWf6FI8h3LQk9\n6RWDP1lqgNFSvIQNZnsOv/uluuH+txMcvDGT2aDzpiPTkuXlmHQXo3GEjOq+bVcU\npbhREB+syJi9klM/ZqOixbDKGSOdZQjBg3n6Tc09K26Cczk/sAs85039L5QSZiEL\niERfSiMWhv3X18sh7z4NLuHV4U1V0sIRzBuyzNJB2bGo4OEudsQtgjceno84n8gz\nQojBqdrvlz1dzRsCQb8pHmc94UDyFKLU0oZAwoG9kkUWu60fmveLr56h7pojrw/9\nQeMdKg6nM6bSAQoI29zSEAuSzUUa6DpIlF0dDrlP/+NZVfOI7Fq2JVKPSmKnclpE\n1DsYw9ZrRJhYnm1O9wuO7unXPQtaWLql401VbUG9EXKoghnHtjPVzPyFgCPs2lPZ\n3uei1TPU0fkedvv+4m5cMg5+a0N1kZmuIABidFVWqdpTSaXY5U24BOuW1W5bYcgF\npx0IUtZOiYrKhbVZ+FA6Y2codyHnCSYqZ91cp2uvqj4=\n=K/aW\n-----END PGP PUBLIC KEY BLOCK-----\n", + "remove_star_url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/remove_star/", + "reply_url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/reply/", + "source_id": 1, + "submissions_url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/submissions/", + "url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/" + }, + { + "add_star_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/add_star/", + "filesystem_id": "LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI=", + "flagged": false, + "interaction_count": 2, + "journalist_designation": "clairvoyant burdock", + "last_updated": "Fri, 29 Jun 2018 19:11:30 GMT", + "number_of_documents": 0, + "number_of_messages": 2, + "public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFs2hGQBEACnIkg5HQpABY/Rpmf8GhN96xqrEBABtK60FgomzdydGUlCip29\nPLzlMVFaAuGNJyo2S2izJr8n8TXmQYAQMP+OGdc+33In047NSCgF3ZGblUkexYKy\n/q8/Jr8YdLDeonJpYG0uQLnA2AA8FJucadkZCc30MPh+g7iPoKsmoRmr32GEpttS\n0XIfzjBhrc3uX1pEH8g9NP1CCHjbkLV1uY/Zo7svwPfbeEicXuK2TEl7ovlx8WYt\nz52sBwfsory2Eyy9D21IUKVBU1tWWeQeTAJrovg+auBZTwSV2+sYM7nE1zjWDDtA\nUSabvtP6O8dDO+vAMxmO80JxYONGfrS0XO5FSATpiApwsxS7o9ZSri3N+vLDQez4\npEQ0dkGa1NgTaUSVDzh+XIFWugd00wWg/rC6d3pZSjZXOA+p7BVUMsAfCLUZMxgz\n7JiqgZhM6TQ/RfReeSYDeUVT5ioImfDsOB79GArt+uvbesLxwLzoAcL6RWtqdK6k\nEcy277g7V5zsASJE6FAaYxS9dkqg9Zc+oSzlNtF7G0Kg3HIjZDwLoG+NzI7f4cMv\nXVka+GSHlWsElgE1My2HryC/SzqeVBbpg0vM8QaIMxiDrnLtjrD28L9Hi/5ab7Rq\nRF43lWWXQeEbKQ6nxLhQrVsM3E1xYx+JJLTBEJbNUo+TwTN7vfhAOpNJ4wARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8TEJJQ0YyRFBHSTNBTUQ3NEhJWVhRN1FLUUIz\nTUNDS05NUTZRNFZQT0wzT1lXTUlETVlETzZBMzdLT0pDWk5UM0dWT0VNQ0RIRUNN\nNFM0T0FYR0dNWjQ1MlNENDU0QTZFQURYTjNaST0+iQI/BBMBCgApBQJbNoRkAhsv\nBQkB4M5cBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQZmOkQ/49FwH1Yw/+\nIHhA2QpvDyThSwWthuh3ytdOJ9VveLO1jtBmDkuZtU/wpMgyVdCMusCOszXePSt5\n3neAcVOYFUBgKQTCmGAOXY8hOMNwHcdl13/ehiAwdj+BvE1OIBdLplCwW41F4esv\nvPvxBQW47oeRNt+u15keNXpWQBjFbB894yWQFlIn6sfEgvB9E53M2UHHn3NUzjKy\nIhC+ItMAodvEPpj34PAVPRxYk3TQkzsA/q9J48nAhY04x7lhSBp8M+jU07iGR2hB\nsewE/cwO5CVew7T7R5b1tl8iGIPmPeb4+zLc2xXy/oBAFRqI0BVdMskhtpmmvUzr\nScKN6GjX9a4TpOhxm3msyeKt5dnc3uOp3e7CBsDnYOTavDHeKvrkKZKukuvAXGt5\ne3RAITcvuOLVdswchwiex3HXq/rrvRHIglBaE56ZKo8XOm9+zBrcZzLjTmY1DChB\nhZGBX2p5tcZEN2h7n04BzFuPGNRB/PJa3A0qc3/aX3sJ8gGTovEt93Yzz6XyM70m\nBoo04NPwFv6JhEIm/qsbGTSFJO5NPONpaZ/54AKMldbIaq56eXz2si3Ltrl1pPIv\nqdmuW0VxMMt0l3xPZe3sBzNfp6MnWGjVYHfTIsXHbHgZWJKiMrhW9o2UjsmNlXUJ\n0asrUWe/LIDPk/5mB42CX1O6lwEkuo7uGoCa2F+8efs=\n=RC5t\n-----END PGP PUBLIC KEY BLOCK-----\n", + "remove_star_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/remove_star/", + "reply_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/reply/", + "source_id": 2, + "submissions_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/submissions/", + "url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/" + } + ] + } + +Individual Source ``[/sources/]`` +------------------------------------------------ + +Requires authentication + +An object representing a single source. + +Response 200 (application/json): + +.. code:: sh + + { + "add_star_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/add_star/", + "filesystem_id": "LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI=", + "flagged": false, + "interaction_count": 2, + "journalist_designation": "clairvoyant burdock", + "last_updated": "Fri, 29 Jun 2018 19:11:30 GMT", + "number_of_documents": 0, + "number_of_messages": 2, + "public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFs2hGQBEACnIkg5HQpABY/Rpmf8GhN96xqrEBABtK60FgomzdydGUlCip29\nPLzlMVFaAuGNJyo2S2izJr8n8TXmQYAQMP+OGdc+33In047NSCgF3ZGblUkexYKy\n/q8/Jr8YdLDeonJpYG0uQLnA2AA8FJucadkZCc30MPh+g7iPoKsmoRmr32GEpttS\n0XIfzjBhrc3uX1pEH8g9NP1CCHjbkLV1uY/Zo7svwPfbeEicXuK2TEl7ovlx8WYt\nz52sBwfsory2Eyy9D21IUKVBU1tWWeQeTAJrovg+auBZTwSV2+sYM7nE1zjWDDtA\nUSabvtP6O8dDO+vAMxmO80JxYONGfrS0XO5FSATpiApwsxS7o9ZSri3N+vLDQez4\npEQ0dkGa1NgTaUSVDzh+XIFWugd00wWg/rC6d3pZSjZXOA+p7BVUMsAfCLUZMxgz\n7JiqgZhM6TQ/RfReeSYDeUVT5ioImfDsOB79GArt+uvbesLxwLzoAcL6RWtqdK6k\nEcy277g7V5zsASJE6FAaYxS9dkqg9Zc+oSzlNtF7G0Kg3HIjZDwLoG+NzI7f4cMv\nXVka+GSHlWsElgE1My2HryC/SzqeVBbpg0vM8QaIMxiDrnLtjrD28L9Hi/5ab7Rq\nRF43lWWXQeEbKQ6nxLhQrVsM3E1xYx+JJLTBEJbNUo+TwTN7vfhAOpNJ4wARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8TEJJQ0YyRFBHSTNBTUQ3NEhJWVhRN1FLUUIz\nTUNDS05NUTZRNFZQT0wzT1lXTUlETVlETzZBMzdLT0pDWk5UM0dWT0VNQ0RIRUNN\nNFM0T0FYR0dNWjQ1MlNENDU0QTZFQURYTjNaST0+iQI/BBMBCgApBQJbNoRkAhsv\nBQkB4M5cBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQZmOkQ/49FwH1Yw/+\nIHhA2QpvDyThSwWthuh3ytdOJ9VveLO1jtBmDkuZtU/wpMgyVdCMusCOszXePSt5\n3neAcVOYFUBgKQTCmGAOXY8hOMNwHcdl13/ehiAwdj+BvE1OIBdLplCwW41F4esv\nvPvxBQW47oeRNt+u15keNXpWQBjFbB894yWQFlIn6sfEgvB9E53M2UHHn3NUzjKy\nIhC+ItMAodvEPpj34PAVPRxYk3TQkzsA/q9J48nAhY04x7lhSBp8M+jU07iGR2hB\nsewE/cwO5CVew7T7R5b1tl8iGIPmPeb4+zLc2xXy/oBAFRqI0BVdMskhtpmmvUzr\nScKN6GjX9a4TpOhxm3msyeKt5dnc3uOp3e7CBsDnYOTavDHeKvrkKZKukuvAXGt5\ne3RAITcvuOLVdswchwiex3HXq/rrvRHIglBaE56ZKo8XOm9+zBrcZzLjTmY1DChB\nhZGBX2p5tcZEN2h7n04BzFuPGNRB/PJa3A0qc3/aX3sJ8gGTovEt93Yzz6XyM70m\nBoo04NPwFv6JhEIm/qsbGTSFJO5NPONpaZ/54AKMldbIaq56eXz2si3Ltrl1pPIv\nqdmuW0VxMMt0l3xPZe3sBzNfp6MnWGjVYHfTIsXHbHgZWJKiMrhW9o2UjsmNlXUJ\n0asrUWe/LIDPk/5mB42CX1O6lwEkuo7uGoCa2F+8efs=\n=RC5t\n-----END PGP PUBLIC KEY BLOCK-----\n", + "remove_star_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/remove_star/", + "reply_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/reply/", + "source_id": 2, + "submissions_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/submissions/", + "url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/" + } + +Get all submissions associated with a source [``GET``] +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Requires authentication. + +.. code:: sh + + GET /api/v1/sources//submissions + +Response 200 (application/json): + +.. code:: sh + + { + "submissions": [ + { + "download_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/submissions/3/download/", + "filename": "1-clairvoyant_burdock-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/", + "submission_id": 3, + "submission_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/submissions/3/" + }, + { + "download_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/submissions/4/download/", + "filename": "2-clairvoyant_burdock-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/", + "submission_id": 4, + "submission_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/submissions/4/" + } + ] + } + +Get a single submission associated with a source [``GET``] +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Requires authentication. + +.. code:: sh + + GET /api/v1/sources//submissions// + +Response 200 (application/json): + +.. code:: sh + + { + "download_url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/submissions/1/download/", + "filename": "1-olfactory_yuppie-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/", + "submission_id": 1, + "submission_url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/submissions/1/" + } + +Add a reply to a source [``POST``] +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Requires authentication. Clients are expected to encrypt replies prior to +submission to the server. Replies should be encrypted to the public key of the +source. + +.. code:: sh + + POST /api/v1/sources//reply + +with the reply in the request body: + +.. code:: sh + + { + "reply": "-----BEGIN PGP MESSAGE-----[...]-----END PGP MESSAGE-----" + } + +Response 201 created (application/json): + +.. code:: sh + + { + "message": "Your reply has been stored" + } + +Replies that do not contain a GPG encrypted message will be rejected: + +Response 412 (application/json): + +.. code:: sh + + { + "message": "You must encrypt replies client side" + } + +Delete a submission [``DELETE``] +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Requires authentication. + +.. code:: sh + + DELETE /api/v1/sources//submissions// + +Response 200: + +.. code:: sh + + { + "message": "Submission deleted" + } + +Download a submission [``GET``] +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Requires authentication. + +.. code:: sh + + GET /api/v1/sources//submissions//download + +Response 200 will have ``Content-Type: application/pgp-encrypted`` and is the +content of the PGP encrypted submission. + +Delete a Source and all their associated submissions [``DELETE``] +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Requires authentication. + +.. code:: sh + + DELETE /api/v1/sources//submissions + +Response 200: + +.. code:: sh + + { + "message": "Source and submissions deleted" + } + +Star a source [``POST``] +^^^^^^^^^^^^^^^^^^^^^^^^ + +Requires authentication. + +.. code:: sh + + POST /api/v1/sources//star/ + +Response 201 created: + +.. code:: sh + + { + "message": "Star added" + } + +Remove a source [``DELETE``] +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Requires authentication. + +.. code:: sh + + DELETE /api/v1/sources//star/ + +Response 200: + +.. code:: sh + + { + "message": "Star removed" + } + +Submission ``[/submissions]`` +----------------------------- + +Get all submissions [``GET``] +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Requires authentication. This gets details of all submissions across sources. + +.. code:: sh + + GET /api/v1/submissions/ + +Response 200: + +.. code:: sh + + { + "submissions": [ + { + "download_url": "/api/v1/sources/HUIQTCLJSN7PACRN4YTC4GUTGD2ZESBTTGAJ5LLFWL4UZY3RP4YE6NO2FL4NZLNFCAJE5TIJS7H3U5YTMC3Z3UNJNCB6PDHU5AMQBRA%3D/submissions/1/download/", + "filename": "1-inspirational_busman-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/HUIQTCLJSN7PACRN4YTC4GUTGD2ZESBTTGAJ5LLFWL4UZY3RP4YE6NO2FL4NZLNFCAJE5TIJS7H3U5YTMC3Z3UNJNCB6PDHU5AMQBRA%3D/", + "submission_id": 1, + "submission_url": "/api/v1/sources/HUIQTCLJSN7PACRN4YTC4GUTGD2ZESBTTGAJ5LLFWL4UZY3RP4YE6NO2FL4NZLNFCAJE5TIJS7H3U5YTMC3Z3UNJNCB6PDHU5AMQBRA%3D/submissions/1/" + }, + { + "download_url": "/api/v1/sources/HUIQTCLJSN7PACRN4YTC4GUTGD2ZESBTTGAJ5LLFWL4UZY3RP4YE6NO2FL4NZLNFCAJE5TIJS7H3U5YTMC3Z3UNJNCB6PDHU5AMQBRA%3D/submissions/2/download/", + "filename": "2-inspirational_busman-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/HUIQTCLJSN7PACRN4YTC4GUTGD2ZESBTTGAJ5LLFWL4UZY3RP4YE6NO2FL4NZLNFCAJE5TIJS7H3U5YTMC3Z3UNJNCB6PDHU5AMQBRA%3D/", + "submission_id": 2, + "submission_url": "/api/v1/sources/HUIQTCLJSN7PACRN4YTC4GUTGD2ZESBTTGAJ5LLFWL4UZY3RP4YE6NO2FL4NZLNFCAJE5TIJS7H3U5YTMC3Z3UNJNCB6PDHU5AMQBRA%3D/submissions/2/" + }, + { + "download_url": "/api/v1/sources/C7YGA52VCSAILDUGWQININHKV7MO3SPUV67HAZKDGKDEVMBZPNGAJSGN7JTG5CZ7WNA4VR36ZKQ7BPI4Z544WBBBOTLRTAYO7LAVPUA%3D/submissions/3/download/", + "filename": "1-masculine_internationalization-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/C7YGA52VCSAILDUGWQININHKV7MO3SPUV67HAZKDGKDEVMBZPNGAJSGN7JTG5CZ7WNA4VR36ZKQ7BPI4Z544WBBBOTLRTAYO7LAVPUA%3D/", + "submission_id": 3, + "submission_url": "/api/v1/sources/C7YGA52VCSAILDUGWQININHKV7MO3SPUV67HAZKDGKDEVMBZPNGAJSGN7JTG5CZ7WNA4VR36ZKQ7BPI4Z544WBBBOTLRTAYO7LAVPUA%3D/submissions/3/" + }, + { + "download_url": "/api/v1/sources/C7YGA52VCSAILDUGWQININHKV7MO3SPUV67HAZKDGKDEVMBZPNGAJSGN7JTG5CZ7WNA4VR36ZKQ7BPI4Z544WBBBOTLRTAYO7LAVPUA%3D/submissions/4/download/", + "filename": "2-masculine_internationalization-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/C7YGA52VCSAILDUGWQININHKV7MO3SPUV67HAZKDGKDEVMBZPNGAJSGN7JTG5CZ7WNA4VR36ZKQ7BPI4Z544WBBBOTLRTAYO7LAVPUA%3D/", + "submission_id": 4, + "submission_url": "/api/v1/sources/C7YGA52VCSAILDUGWQININHKV7MO3SPUV67HAZKDGKDEVMBZPNGAJSGN7JTG5CZ7WNA4VR36ZKQ7BPI4Z544WBBBOTLRTAYO7LAVPUA%3D/submissions/4/" + } + ] + } + +User ``[/user]`` +---------------- + +Get an object representing the current user [``GET``] +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Requires authentication. + +.. code:: sh + + GET /api/v1/user/ + +Response 200: + +.. code:: sh + + { + "is_admin": true, + "last_login": "Fri, 29 Jun 2018 20:13:53 GMT", + "username": "journalist" + } diff --git a/docs/index.rst b/docs/index.rst index c36554ff3a..2023c14715 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -103,6 +103,7 @@ anonymous sources. development/making_pr development/admin_development development/updategui_development + development/journalist_api development/virtual_environments development/virtualizing_tails development/contributor_guidelines From eea5e9e16c471bb72a9a0e7e98e1e239e9a45b04 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Fri, 29 Jun 2018 13:15:21 -0700 Subject: [PATCH 35/71] Journalist API: Update last_access metadata with token auth For auditing journalist access to the server --- securedrop/journalist_app/api.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 725e6ca104..b42909dd01 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -1,3 +1,4 @@ +from datetime import datetime from functools import wraps import json @@ -56,8 +57,16 @@ def get_token(): one_time_code = creds['one_time_code'] try: journalist = Journalist.login(username, password, one_time_code) - return jsonify({'token': journalist.generate_api_token( - expiration=7200), 'expiration': 7200}), 200 + + response = jsonify({'token': journalist.generate_api_token( + expiration=7200), 'expiration': 7200}) + + # Update access metadata + journalist.last_access = datetime.utcnow() + db.session.add(journalist) + db.session.commit() + + return response, 200 except Exception: return abort(403, 'Token authentication failed.') @@ -138,6 +147,9 @@ def single_submission(filesystem_id, submission_id): @token_required def post_reply(filesystem_id): source = get_or_404(Source, filesystem_id, Source.filesystem_id) + if not request.json: + abort(400, 'please send requests in valid JSON') + if 'reply' not in request.json: abort(400, 'reply not found in request body') From b2729f8804c1059ef5a5d819ebef7bbee4723631 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Fri, 29 Jun 2018 13:31:03 -0700 Subject: [PATCH 36/71] AppArmor: Update apache2 profile for journalist API --- .../build-securedrop-app-code-deb-pkg/files/usr.sbin.apache2 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/files/usr.sbin.apache2 b/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/files/usr.sbin.apache2 index 017d9eb77c..964c870efc 100644 --- a/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/files/usr.sbin.apache2 +++ b/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/files/usr.sbin.apache2 @@ -141,6 +141,8 @@ /var/www/securedrop/journalist_app/account.pyc rw, /var/www/securedrop/journalist_app/admin.py r, /var/www/securedrop/journalist_app/admin.pyc rw, + /var/www/securedrop/journalist_app/api.py r, + /var/www/securedrop/journalist_app/api.pyc rw, /var/www/securedrop/journalist_app/col.py r, /var/www/securedrop/journalist_app/col.pyc rw, /var/www/securedrop/journalist_app/decorators.py r, From 2621cf4c19f974e9eb80771844234b87a9f3de8b Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 2 Jul 2018 15:47:51 -0700 Subject: [PATCH 37/71] Journalist API: Remove trailing slashes from all URLs --- docs/development/journalist_api.rst | 14 ++++++------- securedrop/journalist_app/api.py | 32 ++++++++++++++--------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/development/journalist_api.rst b/docs/development/journalist_api.rst index 53519e5c55..b63743494c 100644 --- a/docs/development/journalist_api.rst +++ b/docs/development/journalist_api.rst @@ -85,7 +85,7 @@ should be encrypted to). .. code:: sh - GET /api/v1/sources/ + GET /api/v1/sources Response 200 (application/json): @@ -199,7 +199,7 @@ Requires authentication. .. code:: sh - GET /api/v1/sources//submissions// + GET /api/v1/sources//submissions/ Response 200 (application/json): @@ -259,7 +259,7 @@ Requires authentication. .. code:: sh - DELETE /api/v1/sources//submissions// + DELETE /api/v1/sources//submissions/ Response 200: @@ -305,7 +305,7 @@ Requires authentication. .. code:: sh - POST /api/v1/sources//star/ + POST /api/v1/sources//star Response 201 created: @@ -322,7 +322,7 @@ Requires authentication. .. code:: sh - DELETE /api/v1/sources//star/ + DELETE /api/v1/sources//star Response 200: @@ -342,7 +342,7 @@ Requires authentication. This gets details of all submissions across sources. .. code:: sh - GET /api/v1/submissions/ + GET /api/v1/submissions Response 200: @@ -399,7 +399,7 @@ Requires authentication. .. code:: sh - GET /api/v1/user/ + GET /api/v1/user Response 200: diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index b42909dd01..d94d5ca087 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -43,13 +43,13 @@ def make_blueprint(config): @api.route('/') def get_endpoints(): - endpoints = {'sources_url': '/api/v1/sources/', - 'current_user_url': '/api/v1/user/', - 'submissions_url': '/api/v1/submissions/', - 'auth_token_url': '/api/v1/token/'} + endpoints = {'sources_url': '/api/v1/sources', + 'current_user_url': '/api/v1/user', + 'submissions_url': '/api/v1/submissions', + 'auth_token_url': '/api/v1/token'} return jsonify(endpoints), 200 - @api.route('/token/', methods=['POST']) + @api.route('/token', methods=['POST']) def get_token(): creds = json.loads(request.data) username = creds['username'] @@ -70,20 +70,20 @@ def get_token(): except Exception: return abort(403, 'Token authentication failed.') - @api.route('/sources/', methods=['GET']) + @api.route('/sources', methods=['GET']) @token_required def get_all_sources(): sources = Source.query.all() return jsonify( {'sources': [source.to_json() for source in sources]}), 200 - @api.route('/sources//', methods=['GET']) + @api.route('/sources/', methods=['GET']) @token_required def single_source(filesystem_id): source = get_or_404(Source, filesystem_id, Source.filesystem_id) return jsonify(source.to_json()), 200 - @api.route('/sources//add_star/', methods=['POST']) + @api.route('/sources//add_star', methods=['POST']) @token_required def add_star(filesystem_id): source = get_or_404(Source, filesystem_id, Source.filesystem_id) @@ -91,7 +91,7 @@ def add_star(filesystem_id): db.session.commit() return jsonify({'message': 'Star added'}), 201 - @api.route('/sources//remove_star/', methods=['DELETE']) + @api.route('/sources//remove_star', methods=['DELETE']) @token_required def remove_star(filesystem_id): source = get_or_404(Source, filesystem_id, Source.filesystem_id) @@ -99,8 +99,8 @@ def remove_star(filesystem_id): db.session.commit() return jsonify({'message': 'Star removed'}), 200 - @api.route('/sources//submissions/', methods=['GET', - 'DELETE']) + @api.route('/sources//submissions', methods=['GET', + 'DELETE']) @token_required def all_source_submissions(filesystem_id): if request.method == 'GET': @@ -113,7 +113,7 @@ def all_source_submissions(filesystem_id): utils.delete_collection(source.filesystem_id) return jsonify({'message': 'Source and submissions deleted'}), 200 - @api.route('/sources//submissions//download/', # noqa + @api.route('/sources//submissions//download', # noqa methods=['GET']) @token_required def download_submission(filesystem_id, submission_id): @@ -129,7 +129,7 @@ def download_submission(filesystem_id, submission_id): mimetype="application/pgp-encrypted", as_attachment=True) - @api.route('/sources//submissions//', + @api.route('/sources//submissions/', methods=['GET', 'DELETE']) @token_required def single_submission(filesystem_id, submission_id): @@ -143,7 +143,7 @@ def single_submission(filesystem_id, submission_id): submission) return jsonify({'message': 'Submission deleted'}), 200 - @api.route('/sources//reply/', methods=['POST']) + @api.route('/sources//reply', methods=['POST']) @token_required def post_reply(filesystem_id): source = get_or_404(Source, filesystem_id, Source.filesystem_id) @@ -179,14 +179,14 @@ def post_reply(filesystem_id): db.session.commit() return jsonify({'message': 'Your reply has been stored'}), 201 - @api.route('/submissions/', methods=['GET']) + @api.route('/submissions', methods=['GET']) @token_required def get_all_submissions(): submissions = Submission.query.all() return jsonify({'submissions': [submission.to_json() for submission in submissions]}), 200 - @api.route('/user/', methods=['GET']) + @api.route('/user', methods=['GET']) @token_required def get_current_user(): # Get current user from token From 3be0707334e56fe31e52229fa07de8714a1d3e84 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 2 Jul 2018 16:15:01 -0700 Subject: [PATCH 38/71] Journalist API: Use generic 400 for rejected plaintext reply --- docs/development/journalist_api.rst | 2 +- securedrop/journalist_app/api.py | 2 +- securedrop/tests/test_journalist_api.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/development/journalist_api.rst b/docs/development/journalist_api.rst index b63743494c..b023af90c0 100644 --- a/docs/development/journalist_api.rst +++ b/docs/development/journalist_api.rst @@ -244,7 +244,7 @@ Response 201 created (application/json): Replies that do not contain a GPG encrypted message will be rejected: -Response 412 (application/json): +Response 400 (application/json): .. code:: sh diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index d94d5ca087..0a70d83844 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -170,7 +170,7 @@ def post_reply(filesystem_id): data['reply']) except NotEncrypted: return jsonify( - {'message': 'You must encrypt replies client side'}), 412 + {'message': 'You must encrypt replies client side'}), 400 reply = Reply(user, source, current_app.storage.path(source.filesystem_id, filename)) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index ec7e6b89e1..6ec569747a 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -391,7 +391,7 @@ def test_unencrypted_replies_get_rejected(journalist_app, journalist_api_token, filesystem_id=filesystem_id), data=json.dumps({'reply': reply_content}), headers=get_api_headers(journalist_api_token)) - assert response.status_code == 412 + assert response.status_code == 400 def test_authorized_user_can_add_reply(journalist_app, journalist_api_token, From 586769a6627f233953a4008766dd37df80b5b3a9 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 2 Jul 2018 16:57:38 -0700 Subject: [PATCH 39/71] Journalist API: [], {} are valid JSON --- securedrop/journalist_app/api.py | 2 +- securedrop/tests/test_journalist_api.py | 28 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 0a70d83844..6b3c902f67 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -147,7 +147,7 @@ def single_submission(filesystem_id, submission_id): @token_required def post_reply(filesystem_id): source = get_or_404(Source, filesystem_id, Source.filesystem_id) - if not request.json: + if request.json is None: abort(400, 'please send requests in valid JSON') if 'reply' not in request.json: diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 6ec569747a..3468a24df7 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -465,3 +465,31 @@ def test_reply_without_json_400(journalist_app, journalist_api_token, data='invalid', headers=get_api_headers(journalist_api_token)) assert response.status_code == 400 + + +def test_reply_with_valid_curly_json_400(journalist_app, journalist_api_token, + test_source, test_journo): + with journalist_app.test_client() as app: + filesystem_id = test_source['source'].filesystem_id + response = app.post(url_for('api.post_reply', + filesystem_id=filesystem_id), + data='{}', + headers=get_api_headers(journalist_api_token)) + assert response.status_code == 400 + + json_response = json.loads(response.data) + assert json_response['message'] == 'reply not found in request body' + + +def test_reply_with_valid_square_json_400(journalist_app, journalist_api_token, + test_source, test_journo): + with journalist_app.test_client() as app: + filesystem_id = test_source['source'].filesystem_id + response = app.post(url_for('api.post_reply', + filesystem_id=filesystem_id), + data='[]', + headers=get_api_headers(journalist_api_token)) + assert response.status_code == 400 + + json_response = json.loads(response.data) + assert json_response['message'] == 'reply not found in request body' From 20023473b581ffa244b739532d9c98f45b5e5b17 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 2 Jul 2018 18:01:27 -0700 Subject: [PATCH 40/71] Journalist API: Return ISO8601 format expiraton from /token --- docs/development/journalist_api.rst | 11 ++++++++++- securedrop/journalist_app/api.py | 6 +++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/development/journalist_api.rst b/docs/development/journalist_api.rst index b023af90c0..0a2bf39372 100644 --- a/docs/development/journalist_api.rst +++ b/docs/development/journalist_api.rst @@ -38,12 +38,21 @@ token in the request body: "one_time_code": "123456" } +This will produce a response with your Authorization token: + +.. code:: sh + + { + "expiration": "2018-07-03T02:56:22.700788", + "token": "eyJhbGciOiJIUzI1NiIsImV4cCI6MTUzMDU4NjU4MiwifWF0IjoxNTMwNTc5MzgyfQ.eyJpZCI6MX0.P_PfcLMk1Dq5VCIANo-lJbu0ZyCL2VcT8qf9fIZsTCM" + } + Thereafter in order to authenticate to protected endpoints, send the token in HTTP Authorization header: .. code:: sh - Authorization: Token yourtokengoeshere + Authorization: Token eyJhbGciOiJIUzI1NiIsImV4cCI6MTUzMDU4NjU4MiwifWF0IjoxNTMwNTc5MzgyfQ.eyJpZCI6MX0.P_PfcLMk1Dq5VCIANo-lJbu0ZyCL2VcT8qf9fIZsTCM This header will be checked with each API request to see if it is valid and not yet expired. Tokens expire after 7200 seconds (120 minutes). diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 6b3c902f67..8bfedda85e 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from functools import wraps import json @@ -57,9 +57,9 @@ def get_token(): one_time_code = creds['one_time_code'] try: journalist = Journalist.login(username, password, one_time_code) - + token_expiry = datetime.now() + timedelta(seconds=7200) response = jsonify({'token': journalist.generate_api_token( - expiration=7200), 'expiration': 7200}) + expiration=7200), 'expiration': token_expiry.isoformat()}) # Update access metadata journalist.last_access = datetime.utcnow() From 978f584b3ff1ac7881c71b19be9d1293166e6d3a Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 2 Jul 2018 18:17:11 -0700 Subject: [PATCH 41/71] Journalist API: Add public_key_type field for futureproofing --- docs/development/journalist_api.rst | 3 +++ securedrop/models.py | 1 + 2 files changed, 4 insertions(+) diff --git a/docs/development/journalist_api.rst b/docs/development/journalist_api.rst index 0a2bf39372..2ec4cf2b65 100644 --- a/docs/development/journalist_api.rst +++ b/docs/development/journalist_api.rst @@ -112,6 +112,7 @@ Response 200 (application/json): "number_of_documents": 0, "number_of_messages": 2, "public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFs2hGEBEACX9PSn9146bqup7MD3z4JLC2+m5GtXjOPmHVk7YRPwym7Q1XDx\n1exvXA1b17X6kj7TDPvBv8Gupro9BNAilPja+zB+m2JWKrdTjZYIWzZJ31WIC3Xm\nMs3V2dOZ1fCJlD+r2SiKLVzyODDpAoL42taxHXskhKgZvvUPsZv3abQctUOPtsWG\nKs9acPGGb/NnBVgPpdNzF7bKPpqqIHjMhb3WTEzGl8SYU/mfHx1DELzWmocB4v6s\nV4xMNKopyT44Or/ZeIGJf3SiTTsMSuU8IKfvzQKuCNT9IjWJmnYnYU+Zn/Zx/+q3\n1RWUs5z39e6OTT5qQwpxaharnJyM1u7vWY3R0rcZYkrAWQYgx/Ilf4/W1XSU7qmx\niH43mOupI1vQo0caJZwUvK83es2wmQsTNGJ1wqIU4pQU8nQzrlOuAWT3d/AjTXWh\nfFHMeRwfb2b2kRxp+hgFlC1hwpJG6o1+1kVUFUrh7N7Ln7WZi+UgQ23KGN1bU22D\nmY6fdEnssrODM8ly7AIHYhNOxtw/MnnWNlzt6n7gT26hN9VivXIczVxdkpV/vQz5\nng+olaLfXbf/yF/eTCmlsVdvALpDYYfO2VORcXe3JMTgXFzwQExz4auGdQlzH3ju\nmutOD5d0ETsgYP6lkO9wQrOqoqG/YnX+mUUc2H2wowYi5iFi11sLdbE7LQARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8NDRZR1oyUjc2NDNUWEJBNjVaS1JPNUQ2UUgy\nNlJKN05WREZNUUpWRlNNTTZXQTVXM1pEWE5VWUtHQlRFVVlHRkNBQkJVRURMUTdP\nS1M2NTdXS09HVUhGTFZETFE3NUdXVE9YNEQ0QT0+iQI/BBMBCgApBQJbNoRhAhsv\nBQkB4M5fBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQvPRLirPeGcnf/w//\nXOvsO/N6UtQasiE121xa0AwKtptaRUoprEUP8af3+tQ28Ibo+Io1LLEQDODS4Btu\n7rz2eXjhw6XjvtGYXjbOVtXVHqynPZu2eW+er5cbi+zlSjnN7RyLndsg5PZ457q0\n5b1p4olGEPVTFhjKmFoWcYGmfW2q/QvqD9uz4BQWpevMwpop0k7dWf6FI8h3LQk9\n6RWDP1lqgNFSvIQNZnsOv/uluuH+txMcvDGT2aDzpiPTkuXlmHQXo3GEjOq+bVcU\npbhREB+syJi9klM/ZqOixbDKGSOdZQjBg3n6Tc09K26Cczk/sAs85039L5QSZiEL\niERfSiMWhv3X18sh7z4NLuHV4U1V0sIRzBuyzNJB2bGo4OEudsQtgjceno84n8gz\nQojBqdrvlz1dzRsCQb8pHmc94UDyFKLU0oZAwoG9kkUWu60fmveLr56h7pojrw/9\nQeMdKg6nM6bSAQoI29zSEAuSzUUa6DpIlF0dDrlP/+NZVfOI7Fq2JVKPSmKnclpE\n1DsYw9ZrRJhYnm1O9wuO7unXPQtaWLql401VbUG9EXKoghnHtjPVzPyFgCPs2lPZ\n3uei1TPU0fkedvv+4m5cMg5+a0N1kZmuIABidFVWqdpTSaXY5U24BOuW1W5bYcgF\npx0IUtZOiYrKhbVZ+FA6Y2codyHnCSYqZ91cp2uvqj4=\n=K/aW\n-----END PGP PUBLIC KEY BLOCK-----\n", + "public_key_type": "PGP", "remove_star_url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/remove_star/", "reply_url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/reply/", "source_id": 1, @@ -128,6 +129,7 @@ Response 200 (application/json): "number_of_documents": 0, "number_of_messages": 2, "public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFs2hGQBEACnIkg5HQpABY/Rpmf8GhN96xqrEBABtK60FgomzdydGUlCip29\nPLzlMVFaAuGNJyo2S2izJr8n8TXmQYAQMP+OGdc+33In047NSCgF3ZGblUkexYKy\n/q8/Jr8YdLDeonJpYG0uQLnA2AA8FJucadkZCc30MPh+g7iPoKsmoRmr32GEpttS\n0XIfzjBhrc3uX1pEH8g9NP1CCHjbkLV1uY/Zo7svwPfbeEicXuK2TEl7ovlx8WYt\nz52sBwfsory2Eyy9D21IUKVBU1tWWeQeTAJrovg+auBZTwSV2+sYM7nE1zjWDDtA\nUSabvtP6O8dDO+vAMxmO80JxYONGfrS0XO5FSATpiApwsxS7o9ZSri3N+vLDQez4\npEQ0dkGa1NgTaUSVDzh+XIFWugd00wWg/rC6d3pZSjZXOA+p7BVUMsAfCLUZMxgz\n7JiqgZhM6TQ/RfReeSYDeUVT5ioImfDsOB79GArt+uvbesLxwLzoAcL6RWtqdK6k\nEcy277g7V5zsASJE6FAaYxS9dkqg9Zc+oSzlNtF7G0Kg3HIjZDwLoG+NzI7f4cMv\nXVka+GSHlWsElgE1My2HryC/SzqeVBbpg0vM8QaIMxiDrnLtjrD28L9Hi/5ab7Rq\nRF43lWWXQeEbKQ6nxLhQrVsM3E1xYx+JJLTBEJbNUo+TwTN7vfhAOpNJ4wARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8TEJJQ0YyRFBHSTNBTUQ3NEhJWVhRN1FLUUIz\nTUNDS05NUTZRNFZQT0wzT1lXTUlETVlETzZBMzdLT0pDWk5UM0dWT0VNQ0RIRUNN\nNFM0T0FYR0dNWjQ1MlNENDU0QTZFQURYTjNaST0+iQI/BBMBCgApBQJbNoRkAhsv\nBQkB4M5cBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQZmOkQ/49FwH1Yw/+\nIHhA2QpvDyThSwWthuh3ytdOJ9VveLO1jtBmDkuZtU/wpMgyVdCMusCOszXePSt5\n3neAcVOYFUBgKQTCmGAOXY8hOMNwHcdl13/ehiAwdj+BvE1OIBdLplCwW41F4esv\nvPvxBQW47oeRNt+u15keNXpWQBjFbB894yWQFlIn6sfEgvB9E53M2UHHn3NUzjKy\nIhC+ItMAodvEPpj34PAVPRxYk3TQkzsA/q9J48nAhY04x7lhSBp8M+jU07iGR2hB\nsewE/cwO5CVew7T7R5b1tl8iGIPmPeb4+zLc2xXy/oBAFRqI0BVdMskhtpmmvUzr\nScKN6GjX9a4TpOhxm3msyeKt5dnc3uOp3e7CBsDnYOTavDHeKvrkKZKukuvAXGt5\ne3RAITcvuOLVdswchwiex3HXq/rrvRHIglBaE56ZKo8XOm9+zBrcZzLjTmY1DChB\nhZGBX2p5tcZEN2h7n04BzFuPGNRB/PJa3A0qc3/aX3sJ8gGTovEt93Yzz6XyM70m\nBoo04NPwFv6JhEIm/qsbGTSFJO5NPONpaZ/54AKMldbIaq56eXz2si3Ltrl1pPIv\nqdmuW0VxMMt0l3xPZe3sBzNfp6MnWGjVYHfTIsXHbHgZWJKiMrhW9o2UjsmNlXUJ\n0asrUWe/LIDPk/5mB42CX1O6lwEkuo7uGoCa2F+8efs=\n=RC5t\n-----END PGP PUBLIC KEY BLOCK-----\n", + "public_key_type": "PGP", "remove_star_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/remove_star/", "reply_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/reply/", "source_id": 2, @@ -158,6 +160,7 @@ Response 200 (application/json): "number_of_documents": 0, "number_of_messages": 2, "public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFs2hGQBEACnIkg5HQpABY/Rpmf8GhN96xqrEBABtK60FgomzdydGUlCip29\nPLzlMVFaAuGNJyo2S2izJr8n8TXmQYAQMP+OGdc+33In047NSCgF3ZGblUkexYKy\n/q8/Jr8YdLDeonJpYG0uQLnA2AA8FJucadkZCc30MPh+g7iPoKsmoRmr32GEpttS\n0XIfzjBhrc3uX1pEH8g9NP1CCHjbkLV1uY/Zo7svwPfbeEicXuK2TEl7ovlx8WYt\nz52sBwfsory2Eyy9D21IUKVBU1tWWeQeTAJrovg+auBZTwSV2+sYM7nE1zjWDDtA\nUSabvtP6O8dDO+vAMxmO80JxYONGfrS0XO5FSATpiApwsxS7o9ZSri3N+vLDQez4\npEQ0dkGa1NgTaUSVDzh+XIFWugd00wWg/rC6d3pZSjZXOA+p7BVUMsAfCLUZMxgz\n7JiqgZhM6TQ/RfReeSYDeUVT5ioImfDsOB79GArt+uvbesLxwLzoAcL6RWtqdK6k\nEcy277g7V5zsASJE6FAaYxS9dkqg9Zc+oSzlNtF7G0Kg3HIjZDwLoG+NzI7f4cMv\nXVka+GSHlWsElgE1My2HryC/SzqeVBbpg0vM8QaIMxiDrnLtjrD28L9Hi/5ab7Rq\nRF43lWWXQeEbKQ6nxLhQrVsM3E1xYx+JJLTBEJbNUo+TwTN7vfhAOpNJ4wARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8TEJJQ0YyRFBHSTNBTUQ3NEhJWVhRN1FLUUIz\nTUNDS05NUTZRNFZQT0wzT1lXTUlETVlETzZBMzdLT0pDWk5UM0dWT0VNQ0RIRUNN\nNFM0T0FYR0dNWjQ1MlNENDU0QTZFQURYTjNaST0+iQI/BBMBCgApBQJbNoRkAhsv\nBQkB4M5cBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQZmOkQ/49FwH1Yw/+\nIHhA2QpvDyThSwWthuh3ytdOJ9VveLO1jtBmDkuZtU/wpMgyVdCMusCOszXePSt5\n3neAcVOYFUBgKQTCmGAOXY8hOMNwHcdl13/ehiAwdj+BvE1OIBdLplCwW41F4esv\nvPvxBQW47oeRNt+u15keNXpWQBjFbB894yWQFlIn6sfEgvB9E53M2UHHn3NUzjKy\nIhC+ItMAodvEPpj34PAVPRxYk3TQkzsA/q9J48nAhY04x7lhSBp8M+jU07iGR2hB\nsewE/cwO5CVew7T7R5b1tl8iGIPmPeb4+zLc2xXy/oBAFRqI0BVdMskhtpmmvUzr\nScKN6GjX9a4TpOhxm3msyeKt5dnc3uOp3e7CBsDnYOTavDHeKvrkKZKukuvAXGt5\ne3RAITcvuOLVdswchwiex3HXq/rrvRHIglBaE56ZKo8XOm9+zBrcZzLjTmY1DChB\nhZGBX2p5tcZEN2h7n04BzFuPGNRB/PJa3A0qc3/aX3sJ8gGTovEt93Yzz6XyM70m\nBoo04NPwFv6JhEIm/qsbGTSFJO5NPONpaZ/54AKMldbIaq56eXz2si3Ltrl1pPIv\nqdmuW0VxMMt0l3xPZe3sBzNfp6MnWGjVYHfTIsXHbHgZWJKiMrhW9o2UjsmNlXUJ\n0asrUWe/LIDPk/5mB42CX1O6lwEkuo7uGoCa2F+8efs=\n=RC5t\n-----END PGP PUBLIC KEY BLOCK-----\n", + "public_key_type": "PGP", "remove_star_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/remove_star/", "reply_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/reply/", "source_id": 2, diff --git a/securedrop/models.py b/securedrop/models.py index d259357c1a..c1c410dd33 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -119,6 +119,7 @@ def to_json(self): 'flagged': self.flagged, 'last_updated': self.last_updated, 'interaction_count': self.interaction_count, + 'public_key_type': 'PGP', 'public_key': self.public_key, 'number_of_documents': docs_msg_count['documents'], 'number_of_messages': docs_msg_count['messages'], From 8cf60240a4d3713e146d6eae0209e4acea14272f Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Tue, 3 Jul 2018 15:38:33 -0700 Subject: [PATCH 42/71] Use generic error handlers for Werkzeug's default_exceptions This is for both the main application and the API --- docs/development/journalist_api.rst | 12 +++++++ securedrop/journalist_app/__init__.py | 40 +++++++--------------- securedrop/journalist_app/api.py | 34 ++++++------------ securedrop/journalist_templates/400.html | 4 --- securedrop/journalist_templates/403.html | 4 --- securedrop/journalist_templates/404.html | 4 --- securedrop/journalist_templates/405.html | 4 --- securedrop/journalist_templates/error.html | 7 ++++ securedrop/tests/test_journalist_api.py | 8 ++--- 9 files changed, 47 insertions(+), 70 deletions(-) delete mode 100644 securedrop/journalist_templates/400.html delete mode 100644 securedrop/journalist_templates/403.html delete mode 100644 securedrop/journalist_templates/404.html delete mode 100644 securedrop/journalist_templates/405.html create mode 100644 securedrop/journalist_templates/error.html diff --git a/docs/development/journalist_api.rst b/docs/development/journalist_api.rst index 2ec4cf2b65..8912c9d617 100644 --- a/docs/development/journalist_api.rst +++ b/docs/development/journalist_api.rst @@ -57,6 +57,18 @@ HTTP Authorization header: This header will be checked with each API request to see if it is valid and not yet expired. Tokens expire after 7200 seconds (120 minutes). +Errors +~~~~~~ + +The API will respond to all errors (400-599) with a JSON object with the +following fields: + +.. code:: sh + + { + "message": "This is a detailed error message." + } + Endpoints ~~~~~~~~~ diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index c8a886d039..a2166b63e0 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -7,6 +7,7 @@ from flask_babel import gettext from flask_wtf.csrf import CSRFProtect, CSRFError from os import path +from werkzeug.exceptions import default_exceptions import i18n import template_filters @@ -82,6 +83,18 @@ def handle_csrf_error(e): flash(msg, 'error') return redirect(url_for('main.login')) + def _handle_http_exception(error): + # Workaround for no blueprint-level 404/5 error handlers, see: + # https://github.com/pallets/flask/issues/503#issuecomment-71383286 + handler = app.error_handler_spec['api'][error.code].values()[0] + if request.path.startswith('/api/') and handler: + return handler(error) + + return render_template('error.html', error=error), error.code + + for code in default_exceptions: + app.errorhandler(code)(_handle_http_exception) + i18n.setup_app(config, app) app.jinja_env.trim_blocks = True @@ -142,31 +155,4 @@ def setup_g(): app.register_blueprint(api_blueprint, url_prefix='/api/v1') csrf.exempt(api_blueprint) - @app.errorhandler(400) - def bad_request(message): - return render_template('400.html'), 400 - - @app.errorhandler(403) - def forbidden(message): - return render_template('403.html'), 403 - - @app.errorhandler(404) - def handle_404(message): - # Workaround for no blueprint-level 404/5 error handlers, see: - # https://github.com/pallets/flask/issues/503#issuecomment-71383286 - if request.path.startswith('/api/'): - handler = app.error_handler_spec['api'][404].values()[0] - return handler(message) - - return render_template('404.html'), 404 - - @app.errorhandler(405) - def method_not_allowed(message): - # blueprint level 405 error handlers also need to be applied manually - if request.path.startswith('/api/'): - handler = app.error_handler_spec['api'][405].values()[0] - return handler(message) - - return render_template('405.html'), 405 - return app diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 8bfedda85e..438b329853 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta from functools import wraps import json +from werkzeug.exceptions import default_exceptions from flask import abort, Blueprint, current_app, jsonify, request, send_file @@ -194,28 +195,15 @@ def get_current_user(): user = Journalist.verify_api_token(auth_token) return jsonify(user.to_json()), 200 - @api.errorhandler(403) - def forbidden(message): - response = jsonify({'error': 'forbidden', - 'message': message.description}) - return response, 403 - - @api.errorhandler(400) - def bad_request(message): - response = jsonify({'error': 'bad request', - 'message': message.description}) - return response, 400 - - @api.errorhandler(404) - def not_found(message): - response = jsonify({'error': 'not found', - 'message': message.description}) - return response, 404 - - @api.errorhandler(405) - def method_not_allowed(message): - response = jsonify({'error': 'method not allowed', - 'message': message.description}) - return response, 405 + def _handle_http_exception(error): + # Workaround for no blueprint-level 404/5 error handlers, see: + # https://github.com/pallets/flask/issues/503#issuecomment-71383286 + response = jsonify({'error': error.name, + 'message': error.description}) + + return response, error.code + + for code in default_exceptions: + api.errorhandler(code)(_handle_http_exception) return api diff --git a/securedrop/journalist_templates/400.html b/securedrop/journalist_templates/400.html deleted file mode 100644 index 2f509e0bad..0000000000 --- a/securedrop/journalist_templates/400.html +++ /dev/null @@ -1,4 +0,0 @@ -{% extends "base.html" %} -{% block body %} -

Bad Request

-{% endblock %} diff --git a/securedrop/journalist_templates/403.html b/securedrop/journalist_templates/403.html deleted file mode 100644 index 783dffe10d..0000000000 --- a/securedrop/journalist_templates/403.html +++ /dev/null @@ -1,4 +0,0 @@ -{% extends "base.html" %} -{% block body %} -

Forbidden

-{% endblock %} diff --git a/securedrop/journalist_templates/404.html b/securedrop/journalist_templates/404.html deleted file mode 100644 index 62e0c97975..0000000000 --- a/securedrop/journalist_templates/404.html +++ /dev/null @@ -1,4 +0,0 @@ -{% extends "base.html" %} -{% block body %} -

Not Found

-{% endblock %} diff --git a/securedrop/journalist_templates/405.html b/securedrop/journalist_templates/405.html deleted file mode 100644 index 06caea83e3..0000000000 --- a/securedrop/journalist_templates/405.html +++ /dev/null @@ -1,4 +0,0 @@ -{% extends "base.html" %} -{% block body %} -

Method not allowed

-{% endblock %} diff --git a/securedrop/journalist_templates/error.html b/securedrop/journalist_templates/error.html new file mode 100644 index 0000000000..205cb51d65 --- /dev/null +++ b/securedrop/journalist_templates/error.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block body %} +

{{ error.code }}: {{ error.name }}

+{% if error.description %} +

{{ error.description }}

+{% endif %} +{% endblock %} diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 3468a24df7..fdccc102bd 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -52,7 +52,7 @@ def test_user_cannot_get_an_api_token_with_wrong_password(journalist_app, observed_response = json.loads(response.data) assert response.status_code == 403 - assert observed_response['error'] == 'forbidden' + assert observed_response['error'] == 'Forbidden' def test_user_cannot_get_an_api_token_with_wrong_2fa_token(journalist_app, @@ -67,7 +67,7 @@ def test_user_cannot_get_an_api_token_with_wrong_2fa_token(journalist_app, observed_response = json.loads(response.data) assert response.status_code == 403 - assert observed_response['error'] == 'forbidden' + assert observed_response['error'] == 'Forbidden' def test_authorized_user_gets_all_sources(journalist_app, test_source, @@ -157,7 +157,7 @@ def test_api_404(journalist_app, journalist_api_token): json_response = json.loads(response.data) assert response.status_code == 404 - assert json_response['error'] == 'not found' + assert json_response['error'] == 'Not Found' def test_authorized_user_gets_single_source(journalist_app, test_source, @@ -230,7 +230,7 @@ def test_disallowed_methods_produces_405(journalist_app, test_source, json_response = json.loads(response.data) assert response.status_code == 405 - assert json_response['error'] == 'method not allowed' + assert json_response['error'] == 'Method Not Allowed' def test_authorized_user_can_get_all_submissions(journalist_app, test_source, From 3ab39c730e63ed3361f28dd506120f5dcf654b96 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Tue, 3 Jul 2018 16:00:44 -0700 Subject: [PATCH 43/71] Journalist API: Rename verify_api_token for clarity --- securedrop/journalist_app/api.py | 7 ++++--- securedrop/models.py | 2 +- securedrop/tests/test_journalist_api.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 438b329853..dda900c540 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -23,7 +23,7 @@ def decorated_function(*args, **kwargs): auth_token = auth_header.split(" ")[1] else: auth_token = '' - if not Journalist.verify_api_token(auth_token): + if not Journalist.validate_api_token_and_get_user(auth_token): return abort(403, 'API token is invalid or expired.') return f(*args, **kwargs) return decorated_function @@ -156,7 +156,7 @@ def post_reply(filesystem_id): # Get current user auth_token = request.headers.get('Authorization').split(" ")[1] - user = Journalist.verify_api_token(auth_token) + user = Journalist.validate_api_token_and_get_user(auth_token) data = json.loads(request.data) if not data['reply']: @@ -192,7 +192,8 @@ def get_all_submissions(): def get_current_user(): # Get current user from token auth_token = request.headers.get('Authorization').split(" ")[1] - user = Journalist.verify_api_token(auth_token) + + user = Journalist.validate_api_token_and_get_user(auth_token) return jsonify(user.to_json()), 200 def _handle_http_exception(error): diff --git a/securedrop/models.py b/securedrop/models.py index c1c410dd33..029ba4cb97 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -491,7 +491,7 @@ def generate_api_token(self, expiration): return s.dumps({'id': self.id}).decode('ascii') @staticmethod - def verify_api_token(token): + def validate_api_token_and_get_user(token): s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY']) try: data = s.loads(token) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index fdccc102bd..6b6b8e4c91 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -34,7 +34,7 @@ def test_valid_user_can_get_an_api_token(journalist_app, test_journo): headers=get_api_headers()) observed_response = json.loads(response.data) - assert isinstance(Journalist.verify_api_token( + assert isinstance(Journalist.validate_api_token_and_get_user( observed_response['token']), Journalist) is True assert response.status_code == 200 From 3908748713248f6d76c6bd659c305752ae2430b0 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Tue, 3 Jul 2018 16:16:38 -0700 Subject: [PATCH 44/71] Journalist API: Rename password->passphrase for clarity --- docs/development/journalist_api.rst | 2 +- securedrop/journalist_app/api.py | 4 ++-- securedrop/tests/conftest.py | 2 +- securedrop/tests/test_journalist_api.py | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/development/journalist_api.rst b/docs/development/journalist_api.rst index 8912c9d617..b4beab45b3 100644 --- a/docs/development/journalist_api.rst +++ b/docs/development/journalist_api.rst @@ -34,7 +34,7 @@ token in the request body: { "username": "journalist", - "password": "mypasswordgoeshere", + "passphrase": "monkey potato pizza quality silica growing deduce", "one_time_code": "123456" } diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index dda900c540..7a62baea0f 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -54,10 +54,10 @@ def get_endpoints(): def get_token(): creds = json.loads(request.data) username = creds['username'] - password = creds['password'] + passphrase = creds['passphrase'] one_time_code = creds['one_time_code'] try: - journalist = Journalist.login(username, password, one_time_code) + journalist = Journalist.login(username, passphrase, one_time_code) token_expiry = datetime.now() + timedelta(seconds=7200) response = jsonify({'token': journalist.generate_api_token( expiration=7200), 'expiration': token_expiry.isoformat()}) diff --git a/securedrop/tests/conftest.py b/securedrop/tests/conftest.py index fdbc218575..0721c18904 100644 --- a/securedrop/tests/conftest.py +++ b/securedrop/tests/conftest.py @@ -178,7 +178,7 @@ def journalist_api_token(journalist_app, test_journo): response = app.post(url_for('api.get_token'), data=json.dumps( {'username': test_journo['username'], - 'password': test_journo['password'], + 'passphrase': test_journo['password'], 'one_time_code': valid_token}), headers=utils.api_helper.get_api_headers()) observed_response = json.loads(response.data) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 6b6b8e4c91..2fe3fa3507 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -29,7 +29,7 @@ def test_valid_user_can_get_an_api_token(journalist_app, test_journo): response = app.post(url_for('api.get_token'), data=json.dumps( {'username': test_journo['username'], - 'password': test_journo['password'], + 'passphrase': test_journo['password'], 'one_time_code': valid_token}), headers=get_api_headers()) observed_response = json.loads(response.data) @@ -46,7 +46,7 @@ def test_user_cannot_get_an_api_token_with_wrong_password(journalist_app, response = app.post(url_for('api.get_token'), data=json.dumps( {'username': test_journo['username'], - 'password': 'wrong password', + 'passphrase': 'wrong password', 'one_time_code': valid_token}), headers=get_api_headers()) observed_response = json.loads(response.data) @@ -61,7 +61,7 @@ def test_user_cannot_get_an_api_token_with_wrong_2fa_token(journalist_app, response = app.post(url_for('api.get_token'), data=json.dumps( {'username': test_journo['username'], - 'password': test_journo['password'], + 'passphrase': test_journo['password'], 'one_time_code': '123456'}), headers=get_api_headers()) observed_response = json.loads(response.data) From 545c9d142dca194534688fe9d26233585dc9121f Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Thu, 5 Jul 2018 09:28:47 -0700 Subject: [PATCH 45/71] Journalist API: Modify URI for delete collection endpoint Since both the source and submissions are deleted, the URI should be a DELETE on the source object. --- docs/development/journalist_api.rst | 2 +- securedrop/journalist_app/api.py | 27 ++++++++++++------------- securedrop/tests/test_journalist_api.py | 4 ++-- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/docs/development/journalist_api.rst b/docs/development/journalist_api.rst index b4beab45b3..b016f82c72 100644 --- a/docs/development/journalist_api.rst +++ b/docs/development/journalist_api.rst @@ -312,7 +312,7 @@ Requires authentication. .. code:: sh - DELETE /api/v1/sources//submissions + DELETE /api/v1/sources/ Response 200: diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 7a62baea0f..a68bf272b5 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -78,11 +78,16 @@ def get_all_sources(): return jsonify( {'sources': [source.to_json() for source in sources]}), 200 - @api.route('/sources/', methods=['GET']) + @api.route('/sources/', methods=['GET', 'DELETE']) @token_required def single_source(filesystem_id): - source = get_or_404(Source, filesystem_id, Source.filesystem_id) - return jsonify(source.to_json()), 200 + if request.method == 'GET': + source = get_or_404(Source, filesystem_id, Source.filesystem_id) + return jsonify(source.to_json()), 200 + elif request.method == 'DELETE': + source = get_or_404(Source, filesystem_id, Source.filesystem_id) + utils.delete_collection(source.filesystem_id) + return jsonify({'message': 'Source and submissions deleted'}), 200 @api.route('/sources//add_star', methods=['POST']) @token_required @@ -100,19 +105,13 @@ def remove_star(filesystem_id): db.session.commit() return jsonify({'message': 'Star removed'}), 200 - @api.route('/sources//submissions', methods=['GET', - 'DELETE']) + @api.route('/sources//submissions', methods=['GET']) @token_required def all_source_submissions(filesystem_id): - if request.method == 'GET': - source = get_or_404(Source, filesystem_id, Source.filesystem_id) - return jsonify( - {'submissions': [submission.to_json() for - submission in source.submissions]}), 200 - elif request.method == 'DELETE': - source = get_or_404(Source, filesystem_id, Source.filesystem_id) - utils.delete_collection(source.filesystem_id) - return jsonify({'message': 'Source and submissions deleted'}), 200 + source = get_or_404(Source, filesystem_id, Source.filesystem_id) + return jsonify( + {'submissions': [submission.to_json() for + submission in source.submissions]}), 200 @api.route('/sources//submissions//download', # noqa methods=['GET']) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 2fe3fa3507..2b161f816a 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -117,7 +117,7 @@ def test_user_without_token_cannot_delete_protected_endpoints(journalist_app, with journalist_app.app_context(): filesystem_id = test_source['source'].filesystem_id protected_routes = [ - url_for('api.all_source_submissions', + url_for('api.single_source', filesystem_id=filesystem_id), url_for('api.single_submission', filesystem_id=filesystem_id, @@ -315,7 +315,7 @@ def test_authorized_user_can_delete_source_collection(journalist_app, journalist_api_token): with journalist_app.test_client() as app: filesystem_id = test_source['source'].filesystem_id - response = app.delete(url_for('api.all_source_submissions', + response = app.delete(url_for('api.single_source', filesystem_id=filesystem_id), headers=get_api_headers(journalist_api_token)) From 02432ed80e9963bb2648c5a8fbb8d9722f3209ff Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Thu, 5 Jul 2018 16:04:35 -0700 Subject: [PATCH 46/71] Journalist API: Implement flag for reply --- docs/development/journalist_api.rst | 17 +++++++++++++++++ securedrop/journalist_app/api.py | 8 ++++++++ securedrop/tests/test_journalist_api.py | 18 +++++++++++++++++- 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/development/journalist_api.rst b/docs/development/journalist_api.rst index b016f82c72..b54eef26fb 100644 --- a/docs/development/journalist_api.rst +++ b/docs/development/journalist_api.rst @@ -356,6 +356,23 @@ Response 200: "message": "Star removed" } +Flag a source [``POST``] +^^^^^^^^^^^^^^^^^^^^^^^^ + +Requires authentication. + +.. code:: sh + + POST /api/v1/sources//flag + +Response 200: + +.. code:: sh + + { + "message": "Source flagged for reply" + } + Submission ``[/submissions]`` ----------------------------- diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index a68bf272b5..c2c12476b3 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -105,6 +105,14 @@ def remove_star(filesystem_id): db.session.commit() return jsonify({'message': 'Star removed'}), 200 + @api.route('/sources//flag', methods=['POST']) + @token_required + def flag(filesystem_id): + source = get_or_404(Source, filesystem_id, Source.filesystem_id) + source.flagged = True + db.session.commit() + return jsonify({'message': 'Source flagged for reply'}), 200 + @api.route('/sources//submissions', methods=['GET']) @token_required def all_source_submissions(filesystem_id): diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 2b161f816a..76eb8fee97 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -140,7 +140,8 @@ def test_user_without_token_cannot_post_protected_endpoints(journalist_app, filesystem_id = test_source['source'].filesystem_id protected_routes = [ url_for('api.post_reply', filesystem_id=filesystem_id), - url_for('api.add_star', filesystem_id=filesystem_id) + url_for('api.add_star', filesystem_id=filesystem_id), + url_for('api.flag', filesystem_id=filesystem_id) ] with journalist_app.test_client() as app: @@ -184,6 +185,21 @@ def test_get_non_existant_source_404s(journalist_app, journalist_api_token): assert response.status_code == 404 +def test_authorized_user_can_flag_a_source(journalist_app, test_source, + journalist_api_token): + with journalist_app.test_client() as app: + filesystem_id = test_source['source'].filesystem_id + source_id = test_source['source'].id + response = app.post(url_for('api.flag', + filesystem_id=filesystem_id), + headers=get_api_headers(journalist_api_token)) + + assert response.status_code == 200 + + # Verify that the source was flagged. + assert Source.query.get(source_id).flagged + + def test_authorized_user_can_star_a_source(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: From b490778080a84669a3e4a58deecf93fa6e744596 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Thu, 5 Jul 2018 16:41:51 -0700 Subject: [PATCH 47/71] Journalist API: Add helper auth function --- securedrop/journalist_app/api.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index c2c12476b3..6e6ad50e28 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -11,6 +11,15 @@ from store import NotEncrypted +def get_user_object(request): + """Helper function to use in token_required views that need a user + object + """ + auth_token = request.headers.get('Authorization').split(" ")[1] + user = Journalist.validate_api_token_and_get_user(auth_token) + return user + + def token_required(f): @wraps(f) def decorated_function(*args, **kwargs): @@ -161,9 +170,7 @@ def post_reply(filesystem_id): if 'reply' not in request.json: abort(400, 'reply not found in request body') - # Get current user - auth_token = request.headers.get('Authorization').split(" ")[1] - user = Journalist.validate_api_token_and_get_user(auth_token) + user = get_user_object(request) data = json.loads(request.data) if not data['reply']: @@ -197,12 +204,10 @@ def get_all_submissions(): @api.route('/user', methods=['GET']) @token_required def get_current_user(): - # Get current user from token - auth_token = request.headers.get('Authorization').split(" ")[1] - - user = Journalist.validate_api_token_and_get_user(auth_token) + user = get_user_object(request) return jsonify(user.to_json()), 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 From 172e0fb978c4428cee8abd34a4a531ab855ab7b8 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Thu, 5 Jul 2018 16:42:07 -0700 Subject: [PATCH 48/71] Journalist API: Make get_or_404 clearer --- securedrop/journalist_app/api.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 6e6ad50e28..bf64a5768c 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -40,7 +40,7 @@ def decorated_function(*args, **kwargs): def get_or_404(model, object_id, column=''): if column: - result = model.query.filter(column == object_id).first() + result = model.query.filter(column == object_id).one_or_none() else: result = model.query.get(object_id) if result is None: @@ -91,17 +91,20 @@ def get_all_sources(): @token_required def single_source(filesystem_id): if request.method == 'GET': - source = get_or_404(Source, filesystem_id, Source.filesystem_id) + source = get_or_404(Source, filesystem_id, + column=Source.filesystem_id) return jsonify(source.to_json()), 200 elif request.method == 'DELETE': - source = get_or_404(Source, filesystem_id, Source.filesystem_id) + source = get_or_404(Source, filesystem_id, + column=Source.filesystem_id) utils.delete_collection(source.filesystem_id) return jsonify({'message': 'Source and submissions deleted'}), 200 @api.route('/sources//add_star', methods=['POST']) @token_required def add_star(filesystem_id): - source = get_or_404(Source, filesystem_id, Source.filesystem_id) + source = get_or_404(Source, filesystem_id, + column=Source.filesystem_id) utils.make_star_true(source.filesystem_id) db.session.commit() return jsonify({'message': 'Star added'}), 201 @@ -109,7 +112,8 @@ def add_star(filesystem_id): @api.route('/sources//remove_star', methods=['DELETE']) @token_required def remove_star(filesystem_id): - source = get_or_404(Source, filesystem_id, Source.filesystem_id) + source = get_or_404(Source, filesystem_id, + column=Source.filesystem_id) utils.make_star_false(source.filesystem_id) db.session.commit() return jsonify({'message': 'Star removed'}), 200 @@ -117,7 +121,8 @@ def remove_star(filesystem_id): @api.route('/sources//flag', methods=['POST']) @token_required def flag(filesystem_id): - source = get_or_404(Source, filesystem_id, Source.filesystem_id) + source = get_or_404(Source, filesystem_id, + column=Source.filesystem_id) source.flagged = True db.session.commit() return jsonify({'message': 'Source flagged for reply'}), 200 @@ -125,7 +130,8 @@ def flag(filesystem_id): @api.route('/sources//submissions', methods=['GET']) @token_required def all_source_submissions(filesystem_id): - source = get_or_404(Source, filesystem_id, Source.filesystem_id) + source = get_or_404(Source, filesystem_id, + column=Source.filesystem_id) return jsonify( {'submissions': [submission.to_json() for submission in source.submissions]}), 200 @@ -134,7 +140,8 @@ def all_source_submissions(filesystem_id): methods=['GET']) @token_required def download_submission(filesystem_id, submission_id): - source = get_or_404(Source, filesystem_id, Source.filesystem_id) + source = get_or_404(Source, filesystem_id, + column=Source.filesystem_id) submission = get_or_404(Submission, submission_id) # Mark as downloaded @@ -155,7 +162,8 @@ def single_submission(filesystem_id, submission_id): return jsonify(submission.to_json()), 200 elif request.method == 'DELETE': submission = get_or_404(Submission, submission_id) - source = get_or_404(Source, filesystem_id, Source.filesystem_id) + source = get_or_404(Source, filesystem_id, + column=Source.filesystem_id) utils.delete_file(source.filesystem_id, submission.filename, submission) return jsonify({'message': 'Submission deleted'}), 200 @@ -163,7 +171,8 @@ def single_submission(filesystem_id, submission_id): @api.route('/sources//reply', methods=['POST']) @token_required def post_reply(filesystem_id): - source = get_or_404(Source, filesystem_id, Source.filesystem_id) + source = get_or_404(Source, filesystem_id, + column=Source.filesystem_id) if request.json is None: abort(400, 'please send requests in valid JSON') From b6ad7cd8e891ef21e222e92a08fe5417829c8561 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Thu, 5 Jul 2018 17:14:27 -0700 Subject: [PATCH 49/71] Journalist API: Refactor exception handling on get_token --- securedrop/journalist_app/api.py | 22 +++++++++--- securedrop/tests/test_journalist_api.py | 47 +++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index bf64a5768c..f64cd2656c 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -7,7 +7,9 @@ from db import db from journalist_app import utils -from models import Journalist, Reply, Source, Submission +from models import (Journalist, Reply, Source, Submission, + LoginThrottledException, InvalidUsernameException, + BadTokenException, WrongPasswordException) from store import NotEncrypted @@ -62,9 +64,18 @@ def get_endpoints(): @api.route('/token', methods=['POST']) def get_token(): creds = json.loads(request.data) - username = creds['username'] - passphrase = creds['passphrase'] - one_time_code = creds['one_time_code'] + + username = creds.get('username', None) + passphrase = creds.get('passphrase', None) + one_time_code = creds.get('one_time_code', None) + + if username is None: + return abort(400, 'username field is missing') + if passphrase is None: + return abort(400, 'passphrase field is missing') + if one_time_code is None: + return abort(400, 'one_time_code field is missing') + try: journalist = Journalist.login(username, passphrase, one_time_code) token_expiry = datetime.now() + timedelta(seconds=7200) @@ -77,7 +88,8 @@ def get_token(): db.session.commit() return response, 200 - except Exception: + except (LoginThrottledException, InvalidUsernameException, + BadTokenException, WrongPasswordException): return abort(403, 'Token authentication failed.') @api.route('/sources', methods=['GET']) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 76eb8fee97..5581aca456 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -70,6 +70,53 @@ def test_user_cannot_get_an_api_token_with_wrong_2fa_token(journalist_app, assert observed_response['error'] == 'Forbidden' +def test_user_cannot_get_an_api_token_with_no_passphase_field(journalist_app, + test_journo): + with journalist_app.test_client() as app: + valid_token = TOTP(test_journo['otp_secret']).now() + response = app.post(url_for('api.get_token'), + data=json.dumps( + {'username': test_journo['username'], + 'one_time_code': valid_token}), + headers=get_api_headers()) + observed_response = json.loads(response.data) + + assert response.status_code == 400 + assert observed_response['error'] == 'Bad Request' + assert observed_response['message'] == 'passphrase field is missing' + + +def test_user_cannot_get_an_api_token_with_no_username_field(journalist_app, + test_journo): + with journalist_app.test_client() as app: + valid_token = TOTP(test_journo['otp_secret']).now() + response = app.post(url_for('api.get_token'), + data=json.dumps( + {'passphrase': test_journo['password'], + 'one_time_code': valid_token}), + headers=get_api_headers()) + observed_response = json.loads(response.data) + + assert response.status_code == 400 + assert observed_response['error'] == 'Bad Request' + assert observed_response['message'] == 'username field is missing' + + +def test_user_cannot_get_an_api_token_with_no_otp_field(journalist_app, + test_journo): + with journalist_app.test_client() as app: + response = app.post(url_for('api.get_token'), + data=json.dumps( + {'username': test_journo['username'], + 'passphrase': test_journo['password']}), + headers=get_api_headers()) + observed_response = json.loads(response.data) + + assert response.status_code == 400 + assert observed_response['error'] == 'Bad Request' + assert observed_response['message'] == 'one_time_code field is missing' + + def test_authorized_user_gets_all_sources(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: From e11cf0f3902d555cbf9f30772d7a2837767bac3c Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Thu, 5 Jul 2018 18:02:59 -0700 Subject: [PATCH 50/71] Journalist API: Settle on method for detected plaintext --- securedrop/crypto_util.py | 3 --- securedrop/store.py | 3 +-- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/securedrop/crypto_util.py b/securedrop/crypto_util.py index 739f4fe30e..9848f5a287 100644 --- a/securedrop/crypto_util.py +++ b/securedrop/crypto_util.py @@ -197,9 +197,6 @@ def export_pubkey(self, name): fingerprint = self.getkey(name) return self.gpg.export_keys(fingerprint) - def is_encrypted(self, content): - return bool(self.gpg.list_packets(content).key) - def encrypt(self, plaintext, fingerprints, output=None): # Verify the output path if output: diff --git a/securedrop/store.py b/securedrop/store.py index 142090744f..924f8d5ed3 100644 --- a/securedrop/store.py +++ b/securedrop/store.py @@ -155,8 +155,7 @@ def save_file_submission(self, filesystem_id, count, journalist_filename, def save_pre_encrypted_reply(self, filesystem_id, count, journalist_filename, content): - # if not current_app.crypto_util.is_encrypted(content): # slow - if 'BEGIN PGP MESSAGE' not in content: + if '-----BEGIN PGP MESSAGE-----' not in content.split('\n')[0]: raise NotEncrypted encrypted_file_name = "{0}-{1}-reply.gpg".format(count, From f34f08cc1cb30c9b7171b2405d30f94c3ddf1a7c Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Fri, 6 Jul 2018 13:17:14 -0700 Subject: [PATCH 51/71] Resolve mypy issues on werkzeug.exceptions.default_exceptions The type hints on Python 2 werkzeug.exceptions are out of date. A new release is coming that will include the following resolution: https://github.com/python/typeshed/pull/2185/files#diff-ec3d276cbf9b9d9dae65a3b9bcb4767a Until then, we will need to add an exception for these imports. --- securedrop/journalist_app/__init__.py | 2 +- securedrop/journalist_app/api.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index a2166b63e0..8493dbc285 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -7,7 +7,7 @@ from flask_babel import gettext from flask_wtf.csrf import CSRFProtect, CSRFError from os import path -from werkzeug.exceptions import default_exceptions +from werkzeug.exceptions import default_exceptions # type: ignore import i18n import template_filters diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index f64cd2656c..980fbc9e7e 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta from functools import wraps import json -from werkzeug.exceptions import default_exceptions +from werkzeug.exceptions import default_exceptions # type: ignore from flask import abort, Blueprint, current_app, jsonify, request, send_file @@ -228,7 +228,6 @@ def get_current_user(): user = get_user_object(request) return jsonify(user.to_json()), 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 From 0d86a307de11465ca82ef1a1b31383ee4b82004e Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Fri, 6 Jul 2018 13:41:19 -0700 Subject: [PATCH 52/71] Journalist API: Add security test case for none alg --- securedrop/tests/test_journalist_api.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 5581aca456..148f0e177f 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -5,6 +5,7 @@ from pyotp import TOTP from flask import current_app, url_for +from itsdangerous import TimedJSONWebSignatureSerializer from models import Journalist, Reply, Source, SourceStar, Submission @@ -181,6 +182,22 @@ def test_user_without_token_cannot_delete_protected_endpoints(journalist_app, assert response.status_code == 403 +def test_attacker_cannot_create_valid_token_with_none_alg(journalist_app, + test_source, + test_journo): + with journalist_app.test_client() as app: + filesystem_id = test_source['source'].filesystem_id + s = TimedJSONWebSignatureSerializer('not the secret key', + algorithm_name='none') + attacker_token = s.dumps({'id': test_journo['id']}).decode('ascii') + + response = app.delete(url_for('api.single_source', + filesystem_id=filesystem_id), + headers=get_api_headers(attacker_token)) + + assert response.status_code == 403 + + def test_user_without_token_cannot_post_protected_endpoints(journalist_app, test_source): with journalist_app.app_context(): From 0b9f6c6031938d706a00fd3f7d2628332e838145 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Fri, 6 Jul 2018 14:18:35 -0700 Subject: [PATCH 53/71] Journalist API: Add security test case for deleted user --- securedrop/tests/test_journalist_api.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 148f0e177f..dcbbd35fd3 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -7,6 +7,7 @@ from flask import current_app, url_for from itsdangerous import TimedJSONWebSignatureSerializer +from db import db from models import Journalist, Reply, Source, SourceStar, Submission os.environ['SECUREDROP_ENV'] = 'test' # noqa @@ -198,6 +199,30 @@ def test_attacker_cannot_create_valid_token_with_none_alg(journalist_app, assert response.status_code == 403 +def test_attacker_cannot_use_token_after_admin_deletes(journalist_app, + test_source, + journalist_api_token): + + with journalist_app.test_client() as app: + filesystem_id = test_source['source'].filesystem_id + + # In a scenario where an attacker compromises a journalist workstation + # the admin should be able to delete the user and their token should + # no longer be valid. + attacker = Journalist.validate_api_token_and_get_user( + journalist_api_token) + + db.session.delete(attacker) + db.session.commit() + + # Now this token should not be valid. + response = app.delete(url_for('api.single_source', + filesystem_id=filesystem_id), + headers=get_api_headers(journalist_api_token)) + + assert response.status_code == 403 + + def test_user_without_token_cannot_post_protected_endpoints(journalist_app, test_source): with journalist_app.app_context(): From 785d4d2767238bf78b9491159e3cc6d4f33a8145 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Fri, 6 Jul 2018 17:47:13 -0700 Subject: [PATCH 54/71] Journalist API: Update sources fields - `flagged` -> `is_flagged` - Add `is_starred` field - Make the public_key an object under key 'key' with value type and public --- docs/development/journalist_api.rst | 105 +++++++++++++----------- securedrop/models.py | 9 +- securedrop/tests/test_journalist_api.py | 2 +- 3 files changed, 64 insertions(+), 52 deletions(-) diff --git a/docs/development/journalist_api.rst b/docs/development/journalist_api.rst index b54eef26fb..d750b29207 100644 --- a/docs/development/journalist_api.rst +++ b/docs/development/journalist_api.rst @@ -113,42 +113,48 @@ Response 200 (application/json): .. code:: sh { - "sources": [ - { - "add_star_url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/add_star/", - "filesystem_id": "44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A=", - "flagged": false, - "interaction_count": 2, - "journalist_designation": "olfactory yuppie", - "last_updated": "Fri, 29 Jun 2018 19:11:28 GMT", - "number_of_documents": 0, - "number_of_messages": 2, - "public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFs2hGEBEACX9PSn9146bqup7MD3z4JLC2+m5GtXjOPmHVk7YRPwym7Q1XDx\n1exvXA1b17X6kj7TDPvBv8Gupro9BNAilPja+zB+m2JWKrdTjZYIWzZJ31WIC3Xm\nMs3V2dOZ1fCJlD+r2SiKLVzyODDpAoL42taxHXskhKgZvvUPsZv3abQctUOPtsWG\nKs9acPGGb/NnBVgPpdNzF7bKPpqqIHjMhb3WTEzGl8SYU/mfHx1DELzWmocB4v6s\nV4xMNKopyT44Or/ZeIGJf3SiTTsMSuU8IKfvzQKuCNT9IjWJmnYnYU+Zn/Zx/+q3\n1RWUs5z39e6OTT5qQwpxaharnJyM1u7vWY3R0rcZYkrAWQYgx/Ilf4/W1XSU7qmx\niH43mOupI1vQo0caJZwUvK83es2wmQsTNGJ1wqIU4pQU8nQzrlOuAWT3d/AjTXWh\nfFHMeRwfb2b2kRxp+hgFlC1hwpJG6o1+1kVUFUrh7N7Ln7WZi+UgQ23KGN1bU22D\nmY6fdEnssrODM8ly7AIHYhNOxtw/MnnWNlzt6n7gT26hN9VivXIczVxdkpV/vQz5\nng+olaLfXbf/yF/eTCmlsVdvALpDYYfO2VORcXe3JMTgXFzwQExz4auGdQlzH3ju\nmutOD5d0ETsgYP6lkO9wQrOqoqG/YnX+mUUc2H2wowYi5iFi11sLdbE7LQARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8NDRZR1oyUjc2NDNUWEJBNjVaS1JPNUQ2UUgy\nNlJKN05WREZNUUpWRlNNTTZXQTVXM1pEWE5VWUtHQlRFVVlHRkNBQkJVRURMUTdP\nS1M2NTdXS09HVUhGTFZETFE3NUdXVE9YNEQ0QT0+iQI/BBMBCgApBQJbNoRhAhsv\nBQkB4M5fBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQvPRLirPeGcnf/w//\nXOvsO/N6UtQasiE121xa0AwKtptaRUoprEUP8af3+tQ28Ibo+Io1LLEQDODS4Btu\n7rz2eXjhw6XjvtGYXjbOVtXVHqynPZu2eW+er5cbi+zlSjnN7RyLndsg5PZ457q0\n5b1p4olGEPVTFhjKmFoWcYGmfW2q/QvqD9uz4BQWpevMwpop0k7dWf6FI8h3LQk9\n6RWDP1lqgNFSvIQNZnsOv/uluuH+txMcvDGT2aDzpiPTkuXlmHQXo3GEjOq+bVcU\npbhREB+syJi9klM/ZqOixbDKGSOdZQjBg3n6Tc09K26Cczk/sAs85039L5QSZiEL\niERfSiMWhv3X18sh7z4NLuHV4U1V0sIRzBuyzNJB2bGo4OEudsQtgjceno84n8gz\nQojBqdrvlz1dzRsCQb8pHmc94UDyFKLU0oZAwoG9kkUWu60fmveLr56h7pojrw/9\nQeMdKg6nM6bSAQoI29zSEAuSzUUa6DpIlF0dDrlP/+NZVfOI7Fq2JVKPSmKnclpE\n1DsYw9ZrRJhYnm1O9wuO7unXPQtaWLql401VbUG9EXKoghnHtjPVzPyFgCPs2lPZ\n3uei1TPU0fkedvv+4m5cMg5+a0N1kZmuIABidFVWqdpTSaXY5U24BOuW1W5bYcgF\npx0IUtZOiYrKhbVZ+FA6Y2codyHnCSYqZ91cp2uvqj4=\n=K/aW\n-----END PGP PUBLIC KEY BLOCK-----\n", - "public_key_type": "PGP", - "remove_star_url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/remove_star/", - "reply_url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/reply/", - "source_id": 1, - "submissions_url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/submissions/", - "url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/" - }, - { - "add_star_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/add_star/", - "filesystem_id": "LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI=", - "flagged": false, - "interaction_count": 2, - "journalist_designation": "clairvoyant burdock", - "last_updated": "Fri, 29 Jun 2018 19:11:30 GMT", - "number_of_documents": 0, - "number_of_messages": 2, - "public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFs2hGQBEACnIkg5HQpABY/Rpmf8GhN96xqrEBABtK60FgomzdydGUlCip29\nPLzlMVFaAuGNJyo2S2izJr8n8TXmQYAQMP+OGdc+33In047NSCgF3ZGblUkexYKy\n/q8/Jr8YdLDeonJpYG0uQLnA2AA8FJucadkZCc30MPh+g7iPoKsmoRmr32GEpttS\n0XIfzjBhrc3uX1pEH8g9NP1CCHjbkLV1uY/Zo7svwPfbeEicXuK2TEl7ovlx8WYt\nz52sBwfsory2Eyy9D21IUKVBU1tWWeQeTAJrovg+auBZTwSV2+sYM7nE1zjWDDtA\nUSabvtP6O8dDO+vAMxmO80JxYONGfrS0XO5FSATpiApwsxS7o9ZSri3N+vLDQez4\npEQ0dkGa1NgTaUSVDzh+XIFWugd00wWg/rC6d3pZSjZXOA+p7BVUMsAfCLUZMxgz\n7JiqgZhM6TQ/RfReeSYDeUVT5ioImfDsOB79GArt+uvbesLxwLzoAcL6RWtqdK6k\nEcy277g7V5zsASJE6FAaYxS9dkqg9Zc+oSzlNtF7G0Kg3HIjZDwLoG+NzI7f4cMv\nXVka+GSHlWsElgE1My2HryC/SzqeVBbpg0vM8QaIMxiDrnLtjrD28L9Hi/5ab7Rq\nRF43lWWXQeEbKQ6nxLhQrVsM3E1xYx+JJLTBEJbNUo+TwTN7vfhAOpNJ4wARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8TEJJQ0YyRFBHSTNBTUQ3NEhJWVhRN1FLUUIz\nTUNDS05NUTZRNFZQT0wzT1lXTUlETVlETzZBMzdLT0pDWk5UM0dWT0VNQ0RIRUNN\nNFM0T0FYR0dNWjQ1MlNENDU0QTZFQURYTjNaST0+iQI/BBMBCgApBQJbNoRkAhsv\nBQkB4M5cBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQZmOkQ/49FwH1Yw/+\nIHhA2QpvDyThSwWthuh3ytdOJ9VveLO1jtBmDkuZtU/wpMgyVdCMusCOszXePSt5\n3neAcVOYFUBgKQTCmGAOXY8hOMNwHcdl13/ehiAwdj+BvE1OIBdLplCwW41F4esv\nvPvxBQW47oeRNt+u15keNXpWQBjFbB894yWQFlIn6sfEgvB9E53M2UHHn3NUzjKy\nIhC+ItMAodvEPpj34PAVPRxYk3TQkzsA/q9J48nAhY04x7lhSBp8M+jU07iGR2hB\nsewE/cwO5CVew7T7R5b1tl8iGIPmPeb4+zLc2xXy/oBAFRqI0BVdMskhtpmmvUzr\nScKN6GjX9a4TpOhxm3msyeKt5dnc3uOp3e7CBsDnYOTavDHeKvrkKZKukuvAXGt5\ne3RAITcvuOLVdswchwiex3HXq/rrvRHIglBaE56ZKo8XOm9+zBrcZzLjTmY1DChB\nhZGBX2p5tcZEN2h7n04BzFuPGNRB/PJa3A0qc3/aX3sJ8gGTovEt93Yzz6XyM70m\nBoo04NPwFv6JhEIm/qsbGTSFJO5NPONpaZ/54AKMldbIaq56eXz2si3Ltrl1pPIv\nqdmuW0VxMMt0l3xPZe3sBzNfp6MnWGjVYHfTIsXHbHgZWJKiMrhW9o2UjsmNlXUJ\n0asrUWe/LIDPk/5mB42CX1O6lwEkuo7uGoCa2F+8efs=\n=RC5t\n-----END PGP PUBLIC KEY BLOCK-----\n", - "public_key_type": "PGP", - "remove_star_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/remove_star/", - "reply_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/reply/", - "source_id": 2, - "submissions_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/submissions/", - "url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/" - } - ] + "sources": [ + { + "add_star_url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D/add_star", + "filesystem_id": "S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA=", + "interaction_count": 2, + "is_flagged": false, + "is_starred": true, + "journalist_designation": "fifty-five sorter", + "key": { + "public": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFtAB7UBEAC7xcsW+IgHTVgoYOLjNLWPPySqqClbWmU3SruesHPmgt9X1JnN\nHHHVjAofFPkuUHVHqTPZcnG8YTKEu8mcLQp9nDu6rTFZka2XEhAJ39b9piwmIX7/\nPObgBS0DgfMd4aMb4tQWOTH+QzpPHYZuqDFaXnZ4cppVi5x1lxU+tCqbWSRUOpES\nrupCO5Wh5PJtULUZdcEhe38aST7JCnl/FZWwlh4+uQLXqFUhV5mphGwYk+DIz/8c\nhDsf0InXTiZcivHJb9/kefFwsy1nWSXBs8Cu/T5br0iTl9OFUpuXirlMlzUV1PkM\n182RJvtb5lO8YycEYyHO+26vND9sQIzbSMkMkaUkxe8jD0DCf2Jo5CpEQQ76ft8W\ngsPl9Wuts9gH0p/xdaUZCOprLH7A0Eyp4LDrpVMqshUqmQvNuF1UiKhDqjOzA123\ncrvJZIjD1IxH7FGuZCDGjxAhvTfvio5MF2MLcKIsM5OK94Gor46rduxwitHahPUY\n1FgVTMM1PK3cGoHn+kQ42fk1wpHg8IQH0oKM4VYzOy7oWIoLALFbEA2ODW/MiDMC\n+RdkYwTaw+KuhUUt0DRPTOu8ZY8Id1lH/8b5UdKuPs4rg7RK0wtzszMCDmkK3kpe\nLYidO9mTf8ldzMVToHMQRgUAZ0jkPovtx2QHgwayHV5K9E8kfjjitrjZzQARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8UzdCQkZMNExWRkVXMjZHWFA2QlFFN1pKU0tF\nQzNXSUZGQkNVWE1WTTNWRUVTQzNZNFhETVlaU1Q0VURYWVhIRktRWUQ3SVZDNUFH\nM1ZMRVlINUJMTzc0SDJWT1M0MlJLM1U1T0NIQT0+iQI/BBMBCgApBQJbQAe1Ahsv\nBQkB4dcLBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQIci5eVVVBoiQchAA\nuXET3PAkrDAQ3i4sLRkEQ42gqgmUX+QgYyIYzfQm6QpQRHovHY0HutYY2uuqM+vc\nKU+bEPWTD3y2p0bcfO7xyGBq35gnrkV7aC9edRO6Cyz4WPYhiSsjyQ5WXbHhDB5n\nI9RccxVFxTnlVet+TQFU8rX3djkKUI37Pq1O7HwHfPA6rrnR2Y6/OhS0KpWgVWow\nCt1lZoYro2GB5cghkdFbOsvRdoZWQMzYm2BH5EPoBFX+h1i4JPjZZwlDYsi79GqH\nG/KYY3BGqrgk/7Z/Arc45hw3Qo/R8L1xlj24Yyx9jOHQUR+TuUqrIMvXr0nGemU3\ntUy5FzqJH/wMGWKqvryuq4jOZYykZrmv3ogS1aiZiwYBkr4gY1KRjwb1Z3Hh4gyE\nu6VJXZ1BX2mqm0WOamIyqwG9pyvPdDE1EbjUAqdGZiXYIVznsc1xqhOLSAZF2lrO\nfMORxu8O6vYzJWGGnKnu7eC3Fw/nzyqkCwA9Q2Dmwd6brZNhu8cQKP98+HkIzVja\ndxTOZn8AARbVpzxbc0L5k8yyoqwon8OohbU2+K7OZG9uk/1DcSBCLyWk++yYnSng\n/GhncG1RRU9NY6vFs2AB/nxT+JKi4sUG7I890qh9PrRXwfKtHCcIMw5mc26aEnnq\nFEHSg9czun/Aw2qwuJf5EsveBUAWPSBr9nzjdvbERjI=\n=2Ie1\n-----END PGP PUBLIC KEY BLOCK-----\n", + "type": "PGP" + }, + "last_updated": "Sat, 07 Jul 2018 00:22:12 GMT", + "number_of_documents": 0, + "number_of_messages": 2, + "remove_star_url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D/remove_star", + "reply_url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D/reply", + "source_id": 1, + "submissions_url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D/submissions", + "url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D" + }, + { + "add_star_url": "/api/v1/sources/VY3FJLGRS2DTCEMBYHQ7DU5SM4EBZQZOIASPUEN2IPOLGLZKSATQJWPCRYRP77ELOFC7NJXI66X3IPJPHL6WAQ4ZJEA3NFB3GCXRXWY%3D/add_star", + "filesystem_id": "VY3FJLGRS2DTCEMBYHQ7DU5SM4EBZQZOIASPUEN2IPOLGLZKSATQJWPCRYRP77ELOFC7NJXI66X3IPJPHL6WAQ4ZJEA3NFB3GCXRXWY=", + "interaction_count": 2, + "is_flagged": false, + "is_starred": false, + "journalist_designation": "phobic interception", + "key": { + "public": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFtAB7gBEADNlPnQ4wdWWmYLfoUFmDdVdH9j4nj6+l0NuQIfZBigdglnsKHb\nXWNJ5ay2ozT8oJSjmoKb3gDFrLeIrelibav/jBAelonEfxupmkRq9C3Tw6scKXcp\nyv1LTFK14Lc4p7OOyPA5lQXL0S3KEsRG1tfxVT0rVSCC7dWYz2t1W+aoIJGNvz0c\nlSFGzR7jc5AFXFfZTc9Jh95I7eLk7KjQQf5MTnGXyZMQF4Xltvw3yXTzYEAXt0Ox\n2qWj+P2hXrbiqaOJaPdJQ5+KrmWpWsx86AVHK9Paj56fgyl86V8l+tolnVqW0YEx\ndvGrfyico8pYm9MFqby7xQlkJ3ubfvI365ZjLg6NF0CMNlco/cTVsbxJ6jvZVa71\nFqGXggNPDyaa6qqGLaD6O/B6rw5pKNSejf1Q/0KPxvQ27Aq4eMDNiwVBoGxCAytc\nlM76rM2v5dxp/XUpUVb8A0MpyleLTol2UToCslCy0bJWmK7Z4fmAgB/x+oD8nmbm\nBiZWTNHrOixpGfyEb51ukbXAeEnwONCTjU5gsotvSB95ZwKcUE+pHcibZyhJCZ9M\nUb/Tzl3CSdxCW77XcHXV4/FL0Kk7YIiEHhrNq3PL/Vh4mIwNaJPkMcgEHknVyDkU\nAG6rqkQiPCVGQOOYQIQGfx1+RWfDY5VTWStsKLgVoMm+FCaR13Q68NcCuQARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8VlkzRkpMR1JTMkRUQ0VNQllIUTdEVTVTTTRF\nQlpRWk9JQVNQVUVOMklQT0xHTFpLU0FUUUpXUENSWVJQNzdFTE9GQzdOSlhJNjZY\nM0lQSlBITDZXQVE0WkpFQTNORkIzR0NYUlhXWT0+iQI/BBMBCgApBQJbQAe4Ahsv\nBQkB4dcIBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQIrDFV3/5lPdTGBAA\nqiS73BGiiGl/93CCEmz9OWblAdvMzRR5GsZLaAOF3bUxnMER0F9nlzID2ckYQ2RT\n0+6iJpaTuRNGwnoKfwmoZ39zvWFsGKaXbjU0Aaj5BZW0tL4043wb8gInoLYp1Gmt\niXz1lqWH5M40CVUMD6xXsVtuV6UnCBtsx3ye4X/KdxCWsWCm6kd60GFkuNMMolno\nEFIsnAKTo+ecofwrfUn4kVaNmH/FwTTUyq8U2WtZDPS9RTs4BA4XsttMer1KkyKN\nvNYQkTx9tiIR2dFasIaLyFGbgJ1O9iNMyBwp85LpSSLJDz7iMp65u1/mSFd4KD0A\nnvkYoBJ2P+ds2C1nRd7lZxnwXJ9kKD1SiMMZBemoC15BP2HsHWFSQKv/ZN/E2RhV\n8Uj3Zrrxb4a+KrCJVw9FW0vtStJkDyXroizXn3Zln5HIqus7bGw18c48lc4IqdQH\nPgq2do8bvhDVP6eNsWiTfu7hs1YOlYLeB9sn5ffkT6Ujz9O86nE3F91DS/dtn9tn\n5Evd/QTVTJKPxOYus3WRJGvqw09RAqf8XI5iVOTqVv21SyjDUEy5xf0CvwuYJ0c7\nXJ+BGJdLwP7cHdGth0Gwfn+PgvjgJZOYrttn/rMQqy5j5wcu8dtpqxcvEyJhevpF\nm4VRGVb9gzIIG1/RHSk5qQRQ1sS/LHZj6ySy21Iq7B0=\n=oGey\n-----END PGP PUBLIC KEY BLOCK-----\n", + "type": "PGP" + }, + "last_updated": "Sat, 07 Jul 2018 00:22:14 GMT", + "number_of_documents": 0, + "number_of_messages": 2, + "remove_star_url": "/api/v1/sources/VY3FJLGRS2DTCEMBYHQ7DU5SM4EBZQZOIASPUEN2IPOLGLZKSATQJWPCRYRP77ELOFC7NJXI66X3IPJPHL6WAQ4ZJEA3NFB3GCXRXWY%3D/remove_star", + "reply_url": "/api/v1/sources/VY3FJLGRS2DTCEMBYHQ7DU5SM4EBZQZOIASPUEN2IPOLGLZKSATQJWPCRYRP77ELOFC7NJXI66X3IPJPHL6WAQ4ZJEA3NFB3GCXRXWY%3D/reply", + "source_id": 2, + "submissions_url": "/api/v1/sources/VY3FJLGRS2DTCEMBYHQ7DU5SM4EBZQZOIASPUEN2IPOLGLZKSATQJWPCRYRP77ELOFC7NJXI66X3IPJPHL6WAQ4ZJEA3NFB3GCXRXWY%3D/submissions", + "url": "/api/v1/sources/VY3FJLGRS2DTCEMBYHQ7DU5SM4EBZQZOIASPUEN2IPOLGLZKSATQJWPCRYRP77ELOFC7NJXI66X3IPJPHL6WAQ4ZJEA3NFB3GCXRXWY%3D" + } + ] } Individual Source ``[/sources/]`` @@ -163,21 +169,24 @@ Response 200 (application/json): .. code:: sh { - "add_star_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/add_star/", - "filesystem_id": "LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI=", - "flagged": false, + "add_star_url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D/add_star", + "filesystem_id": "S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA=", "interaction_count": 2, - "journalist_designation": "clairvoyant burdock", - "last_updated": "Fri, 29 Jun 2018 19:11:30 GMT", + "is_flagged": false, + "is_starred": true, + "journalist_designation": "fifty-five sorter", + "key": { + "public": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFtAB7UBEAC7xcsW+IgHTVgoYOLjNLWPPySqqClbWmU3SruesHPmgt9X1JnN\nHHHVjAofFPkuUHVHqTPZcnG8YTKEu8mcLQp9nDu6rTFZka2XEhAJ39b9piwmIX7/\nPObgBS0DgfMd4aMb4tQWOTH+QzpPHYZuqDFaXnZ4cppVi5x1lxU+tCqbWSRUOpES\nrupCO5Wh5PJtULUZdcEhe38aST7JCnl/FZWwlh4+uQLXqFUhV5mphGwYk+DIz/8c\nhDsf0InXTiZcivHJb9/kefFwsy1nWSXBs8Cu/T5br0iTl9OFUpuXirlMlzUV1PkM\n182RJvtb5lO8YycEYyHO+26vND9sQIzbSMkMkaUkxe8jD0DCf2Jo5CpEQQ76ft8W\ngsPl9Wuts9gH0p/xdaUZCOprLH7A0Eyp4LDrpVMqshUqmQvNuF1UiKhDqjOzA123\ncrvJZIjD1IxH7FGuZCDGjxAhvTfvio5MF2MLcKIsM5OK94Gor46rduxwitHahPUY\n1FgVTMM1PK3cGoHn+kQ42fk1wpHg8IQH0oKM4VYzOy7oWIoLALFbEA2ODW/MiDMC\n+RdkYwTaw+KuhUUt0DRPTOu8ZY8Id1lH/8b5UdKuPs4rg7RK0wtzszMCDmkK3kpe\nLYidO9mTf8ldzMVToHMQRgUAZ0jkPovtx2QHgwayHV5K9E8kfjjitrjZzQARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8UzdCQkZMNExWRkVXMjZHWFA2QlFFN1pKU0tF\nQzNXSUZGQkNVWE1WTTNWRUVTQzNZNFhETVlaU1Q0VURYWVhIRktRWUQ3SVZDNUFH\nM1ZMRVlINUJMTzc0SDJWT1M0MlJLM1U1T0NIQT0+iQI/BBMBCgApBQJbQAe1Ahsv\nBQkB4dcLBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQIci5eVVVBoiQchAA\nuXET3PAkrDAQ3i4sLRkEQ42gqgmUX+QgYyIYzfQm6QpQRHovHY0HutYY2uuqM+vc\nKU+bEPWTD3y2p0bcfO7xyGBq35gnrkV7aC9edRO6Cyz4WPYhiSsjyQ5WXbHhDB5n\nI9RccxVFxTnlVet+TQFU8rX3djkKUI37Pq1O7HwHfPA6rrnR2Y6/OhS0KpWgVWow\nCt1lZoYro2GB5cghkdFbOsvRdoZWQMzYm2BH5EPoBFX+h1i4JPjZZwlDYsi79GqH\nG/KYY3BGqrgk/7Z/Arc45hw3Qo/R8L1xlj24Yyx9jOHQUR+TuUqrIMvXr0nGemU3\ntUy5FzqJH/wMGWKqvryuq4jOZYykZrmv3ogS1aiZiwYBkr4gY1KRjwb1Z3Hh4gyE\nu6VJXZ1BX2mqm0WOamIyqwG9pyvPdDE1EbjUAqdGZiXYIVznsc1xqhOLSAZF2lrO\nfMORxu8O6vYzJWGGnKnu7eC3Fw/nzyqkCwA9Q2Dmwd6brZNhu8cQKP98+HkIzVja\ndxTOZn8AARbVpzxbc0L5k8yyoqwon8OohbU2+K7OZG9uk/1DcSBCLyWk++yYnSng\n/GhncG1RRU9NY6vFs2AB/nxT+JKi4sUG7I890qh9PrRXwfKtHCcIMw5mc26aEnnq\nFEHSg9czun/Aw2qwuJf5EsveBUAWPSBr9nzjdvbERjI=\n=2Ie1\n-----END PGP PUBLIC KEY BLOCK-----\n", + "type": "PGP" + }, + "last_updated": "Sat, 07 Jul 2018 00:22:12 GMT", "number_of_documents": 0, "number_of_messages": 2, - "public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFs2hGQBEACnIkg5HQpABY/Rpmf8GhN96xqrEBABtK60FgomzdydGUlCip29\nPLzlMVFaAuGNJyo2S2izJr8n8TXmQYAQMP+OGdc+33In047NSCgF3ZGblUkexYKy\n/q8/Jr8YdLDeonJpYG0uQLnA2AA8FJucadkZCc30MPh+g7iPoKsmoRmr32GEpttS\n0XIfzjBhrc3uX1pEH8g9NP1CCHjbkLV1uY/Zo7svwPfbeEicXuK2TEl7ovlx8WYt\nz52sBwfsory2Eyy9D21IUKVBU1tWWeQeTAJrovg+auBZTwSV2+sYM7nE1zjWDDtA\nUSabvtP6O8dDO+vAMxmO80JxYONGfrS0XO5FSATpiApwsxS7o9ZSri3N+vLDQez4\npEQ0dkGa1NgTaUSVDzh+XIFWugd00wWg/rC6d3pZSjZXOA+p7BVUMsAfCLUZMxgz\n7JiqgZhM6TQ/RfReeSYDeUVT5ioImfDsOB79GArt+uvbesLxwLzoAcL6RWtqdK6k\nEcy277g7V5zsASJE6FAaYxS9dkqg9Zc+oSzlNtF7G0Kg3HIjZDwLoG+NzI7f4cMv\nXVka+GSHlWsElgE1My2HryC/SzqeVBbpg0vM8QaIMxiDrnLtjrD28L9Hi/5ab7Rq\nRF43lWWXQeEbKQ6nxLhQrVsM3E1xYx+JJLTBEJbNUo+TwTN7vfhAOpNJ4wARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8TEJJQ0YyRFBHSTNBTUQ3NEhJWVhRN1FLUUIz\nTUNDS05NUTZRNFZQT0wzT1lXTUlETVlETzZBMzdLT0pDWk5UM0dWT0VNQ0RIRUNN\nNFM0T0FYR0dNWjQ1MlNENDU0QTZFQURYTjNaST0+iQI/BBMBCgApBQJbNoRkAhsv\nBQkB4M5cBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQZmOkQ/49FwH1Yw/+\nIHhA2QpvDyThSwWthuh3ytdOJ9VveLO1jtBmDkuZtU/wpMgyVdCMusCOszXePSt5\n3neAcVOYFUBgKQTCmGAOXY8hOMNwHcdl13/ehiAwdj+BvE1OIBdLplCwW41F4esv\nvPvxBQW47oeRNt+u15keNXpWQBjFbB894yWQFlIn6sfEgvB9E53M2UHHn3NUzjKy\nIhC+ItMAodvEPpj34PAVPRxYk3TQkzsA/q9J48nAhY04x7lhSBp8M+jU07iGR2hB\nsewE/cwO5CVew7T7R5b1tl8iGIPmPeb4+zLc2xXy/oBAFRqI0BVdMskhtpmmvUzr\nScKN6GjX9a4TpOhxm3msyeKt5dnc3uOp3e7CBsDnYOTavDHeKvrkKZKukuvAXGt5\ne3RAITcvuOLVdswchwiex3HXq/rrvRHIglBaE56ZKo8XOm9+zBrcZzLjTmY1DChB\nhZGBX2p5tcZEN2h7n04BzFuPGNRB/PJa3A0qc3/aX3sJ8gGTovEt93Yzz6XyM70m\nBoo04NPwFv6JhEIm/qsbGTSFJO5NPONpaZ/54AKMldbIaq56eXz2si3Ltrl1pPIv\nqdmuW0VxMMt0l3xPZe3sBzNfp6MnWGjVYHfTIsXHbHgZWJKiMrhW9o2UjsmNlXUJ\n0asrUWe/LIDPk/5mB42CX1O6lwEkuo7uGoCa2F+8efs=\n=RC5t\n-----END PGP PUBLIC KEY BLOCK-----\n", - "public_key_type": "PGP", - "remove_star_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/remove_star/", - "reply_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/reply/", - "source_id": 2, - "submissions_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/submissions/", - "url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/" + "remove_star_url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D/remove_star", + "reply_url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D/reply", + "source_id": 1, + "submissions_url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D/submissions", + "url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D" } Get all submissions associated with a source [``GET``] diff --git a/securedrop/models.py b/securedrop/models.py index 029ba4cb97..289f2760d4 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -116,11 +116,14 @@ def to_json(self): 'source_id': self.id, 'filesystem_id': self.filesystem_id, 'journalist_designation': self.journalist_designation, - 'flagged': self.flagged, + 'is_flagged': self.flagged, + 'is_starred': True if self.star else False, 'last_updated': self.last_updated, 'interaction_count': self.interaction_count, - 'public_key_type': 'PGP', - 'public_key': self.public_key, + 'key': { + 'type': 'PGP', + 'public': self.public_key + }, 'number_of_documents': docs_msg_count['documents'], 'number_of_messages': docs_msg_count['messages'], 'submissions_url': url_for('api.all_source_submissions', diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index dcbbd35fd3..c71de2b282 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -262,7 +262,7 @@ def test_authorized_user_gets_single_source(journalist_app, test_source, data = json.loads(response.data) assert data['source_id'] == test_source['source'].id - assert 'BEGIN PGP PUBLIC KEY' in data['public_key'] + assert 'BEGIN PGP PUBLIC KEY' in data['key']['public'] def test_get_non_existant_source_404s(journalist_app, journalist_api_token): From 8685884b44a4477e056e1118df6cdf43f17d3853 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Fri, 6 Jul 2018 17:48:19 -0700 Subject: [PATCH 55/71] Journalist API: Add test case for trailing slash on end of URL No URL should produce a 500, this commit ensures that they are handled as expected (as a 404) --- securedrop/tests/test_journalist_api.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index c71de2b282..1b0e4fa920 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -250,6 +250,19 @@ def test_api_404(journalist_app, journalist_api_token): assert json_response['error'] == 'Not Found' +def test_trailing_slash_cleanly_404s(journalist_app, test_source, + journalist_api_token): + with journalist_app.test_client() as app: + filesystem_id = test_source['source'].filesystem_id + response = app.get(url_for('api.single_source', + filesystem_id=filesystem_id) + '/', + headers=get_api_headers(journalist_api_token)) + json_response = json.loads(response.data) + + assert response.status_code == 404 + assert json_response['error'] == 'Not Found' + + def test_authorized_user_gets_single_source(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: From 52d6c5a5b0b3d13dae55b9c97074696a8d7b34db Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Fri, 6 Jul 2018 18:19:17 -0700 Subject: [PATCH 56/71] Journalist API: Increase API token expiration for UX reasons This means that we do not currently implement refresh tokens. --- docs/development/journalist_api.rst | 6 +++++- securedrop/journalist_app/api.py | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/development/journalist_api.rst b/docs/development/journalist_api.rst index d750b29207..28fec01f49 100644 --- a/docs/development/journalist_api.rst +++ b/docs/development/journalist_api.rst @@ -55,7 +55,11 @@ HTTP Authorization header: Authorization: Token eyJhbGciOiJIUzI1NiIsImV4cCI6MTUzMDU4NjU4MiwifWF0IjoxNTMwNTc5MzgyfQ.eyJpZCI6MX0.P_PfcLMk1Dq5VCIANo-lJbu0ZyCL2VcT8qf9fIZsTCM This header will be checked with each API request to see if it is valid and -not yet expired. Tokens expire after 7200 seconds (120 minutes). +not yet expired. Tokens currently expire after 8 hours, but note that clients +should use the expiration time provided in the response to determine when +the token will expire. After the token expires point, users must +login again. Clients implementing logout functionality should delete tokens +locally upon logout. Errors ~~~~~~ diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 980fbc9e7e..4228e76f52 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -13,6 +13,9 @@ from store import NotEncrypted +TOKEN_EXPIRATION_MINS = 60 * 8 + + def get_user_object(request): """Helper function to use in token_required views that need a user object @@ -78,9 +81,11 @@ def get_token(): try: journalist = Journalist.login(username, passphrase, one_time_code) - token_expiry = datetime.now() + timedelta(seconds=7200) + token_expiry = datetime.now() + timedelta( + seconds=TOKEN_EXPIRATION_MINS * 60) response = jsonify({'token': journalist.generate_api_token( - expiration=7200), 'expiration': token_expiry.isoformat()}) + expiration=TOKEN_EXPIRATION_MINS * 60), + 'expiration': token_expiry.isoformat()}) # Update access metadata journalist.last_access = datetime.utcnow() From c324abe8e8a9a28a47244147a6dc80802b93315e Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 9 Jul 2018 14:02:40 -0700 Subject: [PATCH 57/71] Journalist API: Return ISO 8601 compliant dates from API --- docs/development/journalist_api.rst | 84 ++++++++++++++--------------- securedrop/journalist_app/api.py | 4 +- securedrop/models.py | 4 +- 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/docs/development/journalist_api.rst b/docs/development/journalist_api.rst index 28fec01f49..9a55897992 100644 --- a/docs/development/journalist_api.rst +++ b/docs/development/journalist_api.rst @@ -43,8 +43,8 @@ This will produce a response with your Authorization token: .. code:: sh { - "expiration": "2018-07-03T02:56:22.700788", - "token": "eyJhbGciOiJIUzI1NiIsImV4cCI6MTUzMDU4NjU4MiwifWF0IjoxNTMwNTc5MzgyfQ.eyJpZCI6MX0.P_PfcLMk1Dq5VCIANo-lJbu0ZyCL2VcT8qf9fIZsTCM" + "expiration": "2018-07-10T04:29:41.696321Z", + "token": "eyJhbGciOiJIUzI1NiIsImV4cCI6MTUzMTE5Njk4MSwiaWF0IjoxNTMxMTY4MTgxfQ.eyJpZCI6MX0.TBSvfrICMxtvWgpVZzqTl6wHYNQuGPOaZpuAKwwIXXo" } Thereafter in order to authenticate to protected endpoints, send the token in @@ -119,44 +119,44 @@ Response 200 (application/json): { "sources": [ { - "add_star_url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D/add_star", - "filesystem_id": "S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA=", + "add_star_url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D/add_star", + "filesystem_id": "6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY=", "interaction_count": 2, "is_flagged": false, - "is_starred": true, - "journalist_designation": "fifty-five sorter", + "is_starred": false, + "journalist_designation": "tight-fitting horsetail", "key": { - "public": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFtAB7UBEAC7xcsW+IgHTVgoYOLjNLWPPySqqClbWmU3SruesHPmgt9X1JnN\nHHHVjAofFPkuUHVHqTPZcnG8YTKEu8mcLQp9nDu6rTFZka2XEhAJ39b9piwmIX7/\nPObgBS0DgfMd4aMb4tQWOTH+QzpPHYZuqDFaXnZ4cppVi5x1lxU+tCqbWSRUOpES\nrupCO5Wh5PJtULUZdcEhe38aST7JCnl/FZWwlh4+uQLXqFUhV5mphGwYk+DIz/8c\nhDsf0InXTiZcivHJb9/kefFwsy1nWSXBs8Cu/T5br0iTl9OFUpuXirlMlzUV1PkM\n182RJvtb5lO8YycEYyHO+26vND9sQIzbSMkMkaUkxe8jD0DCf2Jo5CpEQQ76ft8W\ngsPl9Wuts9gH0p/xdaUZCOprLH7A0Eyp4LDrpVMqshUqmQvNuF1UiKhDqjOzA123\ncrvJZIjD1IxH7FGuZCDGjxAhvTfvio5MF2MLcKIsM5OK94Gor46rduxwitHahPUY\n1FgVTMM1PK3cGoHn+kQ42fk1wpHg8IQH0oKM4VYzOy7oWIoLALFbEA2ODW/MiDMC\n+RdkYwTaw+KuhUUt0DRPTOu8ZY8Id1lH/8b5UdKuPs4rg7RK0wtzszMCDmkK3kpe\nLYidO9mTf8ldzMVToHMQRgUAZ0jkPovtx2QHgwayHV5K9E8kfjjitrjZzQARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8UzdCQkZMNExWRkVXMjZHWFA2QlFFN1pKU0tF\nQzNXSUZGQkNVWE1WTTNWRUVTQzNZNFhETVlaU1Q0VURYWVhIRktRWUQ3SVZDNUFH\nM1ZMRVlINUJMTzc0SDJWT1M0MlJLM1U1T0NIQT0+iQI/BBMBCgApBQJbQAe1Ahsv\nBQkB4dcLBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQIci5eVVVBoiQchAA\nuXET3PAkrDAQ3i4sLRkEQ42gqgmUX+QgYyIYzfQm6QpQRHovHY0HutYY2uuqM+vc\nKU+bEPWTD3y2p0bcfO7xyGBq35gnrkV7aC9edRO6Cyz4WPYhiSsjyQ5WXbHhDB5n\nI9RccxVFxTnlVet+TQFU8rX3djkKUI37Pq1O7HwHfPA6rrnR2Y6/OhS0KpWgVWow\nCt1lZoYro2GB5cghkdFbOsvRdoZWQMzYm2BH5EPoBFX+h1i4JPjZZwlDYsi79GqH\nG/KYY3BGqrgk/7Z/Arc45hw3Qo/R8L1xlj24Yyx9jOHQUR+TuUqrIMvXr0nGemU3\ntUy5FzqJH/wMGWKqvryuq4jOZYykZrmv3ogS1aiZiwYBkr4gY1KRjwb1Z3Hh4gyE\nu6VJXZ1BX2mqm0WOamIyqwG9pyvPdDE1EbjUAqdGZiXYIVznsc1xqhOLSAZF2lrO\nfMORxu8O6vYzJWGGnKnu7eC3Fw/nzyqkCwA9Q2Dmwd6brZNhu8cQKP98+HkIzVja\ndxTOZn8AARbVpzxbc0L5k8yyoqwon8OohbU2+K7OZG9uk/1DcSBCLyWk++yYnSng\n/GhncG1RRU9NY6vFs2AB/nxT+JKi4sUG7I890qh9PrRXwfKtHCcIMw5mc26aEnnq\nFEHSg9czun/Aw2qwuJf5EsveBUAWPSBr9nzjdvbERjI=\n=2Ie1\n-----END PGP PUBLIC KEY BLOCK-----\n", + "public": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFtDtxcBEACaduNhYLXGe3brGRpSFeIe7j6hBVGCfDjfRV0KL2u8VUPIfKIZ\n4kkCtqdfTyTObSPxaTvd20LF+ENI9konXQAf2pBGxFBLRHx0cqwSlHvc6OjCJuXI\nmG94tPThMrw5xLgvhih8/PzdUvsC7vswMp5uAK5jIVam25pXJgjCtivEGVFars0q\n4H5ti3r3GHKhHA7ictjBesTDOiRT0NkCDPDjxv2V+AlNPjfFzf5lPw1zSFVZ5A52\n1OzgadqTZfj+/aZQcQkUA8omoTlcSJI4Mf/Dvn78j4A9bJO643U54rb8Nknnm2u8\niA4RTiGo0uTifZ1Q17tDJBlRTgheH4zrx8LJzEKY1RJDQt2K5RcHsU41TdbDVtbO\nRP6RX/xYRgNzyhUue3Vn3LtMzjmkbti3tiOtIAqUMgKuA6KTNY1uViDgF0hcgi6H\nzIWsoYBZx7RJhK5nEowmddTbN+Fp8gOoUhbCyKFo+f7W6dgVDl8KKJbUapaZpMnr\nk5ldS768Q2KqArSarZTCkUPSYHMvqBGP7ZR1l0HUY4qL1WGtibq3fTE/GPdyadc7\n98slu5/30prXgsV4/mTwWvBZQlixNSM0Rdw69sannDvtRfnH2ocF4oQKOf2htwQ1\nbLvknlOXvXZEy4ctu0FXoZUFjgXHPU5y7+XaxCfspfNSFB+xhzs8FWK9ywARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8NkNIQUQ3NFlVV1RZQk5FWVU0RFlSNlUyTExO\nWkdaR0hBWkFCNU82UjVXSEQyRFVGRlpTU05LS0lETk1BV1FXSUszQUFLWUo0NzdR\nRUc2UFJJRDVDUDJSUUoyU1dDUDNJTlBVNlhHWT0+iQI/BBMBCgApBQJbQ7cXAhsv\nBQkB4MqpBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQcNhgsP2ye/LjVw//\nXe/iXqAoJMc0o2x+/1Z0xyHP/uqy2nJ/ClVibhvwwDUoDGZb3l+1KZGqF9irdhOU\nXAE/N0taKG6LSAckW9I+2nXpUSNH4iXv8uwzW6VsNAY+BSmgRnS/KLsSr6DDFHek\n5zs6gftUYQoTOdpL3CxczGDnh2tJeJmGJcobAiCj8jArlLZhtK2sYHKEBDGW67rT\nskOTuRtRNYCDiOo/0WycD4AKtlZNCI9Az4Sn5Zq9ODlzwBKx7j2CdykKXeTSxqdd\nGZQc4+CD6xempUp2SKsacIhoQKfAw6q8L6pxcc3AJDtImCQJ7qNrEcLQCafQpLhx\nObXeDPNruxLHL+70rpIUs7bj5+ChZUwcfCzuT3bEqpKHCq+8vfpHJVgopUI1gg3G\nr88U6REdXobAQqh5AN3AFYFdg9P4XiFIpAnp7vCWkGGGULEQ0vnTZcASSdbj+MRI\n8v1qk0lZOMOn9JYYc3dePq5okZhfMqPOubEwaji8FDTOmhWnQiigIx8SS/XhQonY\nxJ14xgRoWCqwdBjrrjmJf+OScfJBZvFchf7mwkPkOUrdHCgUkWzGaUI4TLmh9xSk\nTy8fScG/U4JVlH0V8/xY01DOXvGRo3DAEn3ptm+j48fI1coH7jy0n9pQW4r2BNqX\n5MokpDpo3g5AaQr54IV1KBcetYBy301GxIytGaYThn8=\n=b/Mq\n-----END PGP PUBLIC KEY BLOCK-----\n", "type": "PGP" }, - "last_updated": "Sat, 07 Jul 2018 00:22:12 GMT", + "last_updated": "2018-07-09T19:27:17.879344Z", "number_of_documents": 0, "number_of_messages": 2, - "remove_star_url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D/remove_star", - "reply_url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D/reply", + "remove_star_url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D/remove_star", + "reply_url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D/reply", "source_id": 1, - "submissions_url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D/submissions", - "url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D" + "submissions_url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D/submissions", + "url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D" }, { - "add_star_url": "/api/v1/sources/VY3FJLGRS2DTCEMBYHQ7DU5SM4EBZQZOIASPUEN2IPOLGLZKSATQJWPCRYRP77ELOFC7NJXI66X3IPJPHL6WAQ4ZJEA3NFB3GCXRXWY%3D/add_star", - "filesystem_id": "VY3FJLGRS2DTCEMBYHQ7DU5SM4EBZQZOIASPUEN2IPOLGLZKSATQJWPCRYRP77ELOFC7NJXI66X3IPJPHL6WAQ4ZJEA3NFB3GCXRXWY=", + "add_star_url": "/api/v1/sources/VITB6XPYQS3MB2HFEGF3NZYD5G5ZMGMOA2VFKETEGYYKDAY4DFI53ZJMNGCZKWMG2SON7XAUXNRWEURNJ2OVY4QJPGGMGO4TDMMWFSQ%3D/add_star", + "filesystem_id": "VITB6XPYQS3MB2HFEGF3NZYD5G5ZMGMOA2VFKETEGYYKDAY4DFI53ZJMNGCZKWMG2SON7XAUXNRWEURNJ2OVY4QJPGGMGO4TDMMWFSQ=", "interaction_count": 2, "is_flagged": false, "is_starred": false, - "journalist_designation": "phobic interception", + "journalist_designation": "existential irreverence", "key": { - "public": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFtAB7gBEADNlPnQ4wdWWmYLfoUFmDdVdH9j4nj6+l0NuQIfZBigdglnsKHb\nXWNJ5ay2ozT8oJSjmoKb3gDFrLeIrelibav/jBAelonEfxupmkRq9C3Tw6scKXcp\nyv1LTFK14Lc4p7OOyPA5lQXL0S3KEsRG1tfxVT0rVSCC7dWYz2t1W+aoIJGNvz0c\nlSFGzR7jc5AFXFfZTc9Jh95I7eLk7KjQQf5MTnGXyZMQF4Xltvw3yXTzYEAXt0Ox\n2qWj+P2hXrbiqaOJaPdJQ5+KrmWpWsx86AVHK9Paj56fgyl86V8l+tolnVqW0YEx\ndvGrfyico8pYm9MFqby7xQlkJ3ubfvI365ZjLg6NF0CMNlco/cTVsbxJ6jvZVa71\nFqGXggNPDyaa6qqGLaD6O/B6rw5pKNSejf1Q/0KPxvQ27Aq4eMDNiwVBoGxCAytc\nlM76rM2v5dxp/XUpUVb8A0MpyleLTol2UToCslCy0bJWmK7Z4fmAgB/x+oD8nmbm\nBiZWTNHrOixpGfyEb51ukbXAeEnwONCTjU5gsotvSB95ZwKcUE+pHcibZyhJCZ9M\nUb/Tzl3CSdxCW77XcHXV4/FL0Kk7YIiEHhrNq3PL/Vh4mIwNaJPkMcgEHknVyDkU\nAG6rqkQiPCVGQOOYQIQGfx1+RWfDY5VTWStsKLgVoMm+FCaR13Q68NcCuQARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8VlkzRkpMR1JTMkRUQ0VNQllIUTdEVTVTTTRF\nQlpRWk9JQVNQVUVOMklQT0xHTFpLU0FUUUpXUENSWVJQNzdFTE9GQzdOSlhJNjZY\nM0lQSlBITDZXQVE0WkpFQTNORkIzR0NYUlhXWT0+iQI/BBMBCgApBQJbQAe4Ahsv\nBQkB4dcIBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQIrDFV3/5lPdTGBAA\nqiS73BGiiGl/93CCEmz9OWblAdvMzRR5GsZLaAOF3bUxnMER0F9nlzID2ckYQ2RT\n0+6iJpaTuRNGwnoKfwmoZ39zvWFsGKaXbjU0Aaj5BZW0tL4043wb8gInoLYp1Gmt\niXz1lqWH5M40CVUMD6xXsVtuV6UnCBtsx3ye4X/KdxCWsWCm6kd60GFkuNMMolno\nEFIsnAKTo+ecofwrfUn4kVaNmH/FwTTUyq8U2WtZDPS9RTs4BA4XsttMer1KkyKN\nvNYQkTx9tiIR2dFasIaLyFGbgJ1O9iNMyBwp85LpSSLJDz7iMp65u1/mSFd4KD0A\nnvkYoBJ2P+ds2C1nRd7lZxnwXJ9kKD1SiMMZBemoC15BP2HsHWFSQKv/ZN/E2RhV\n8Uj3Zrrxb4a+KrCJVw9FW0vtStJkDyXroizXn3Zln5HIqus7bGw18c48lc4IqdQH\nPgq2do8bvhDVP6eNsWiTfu7hs1YOlYLeB9sn5ffkT6Ujz9O86nE3F91DS/dtn9tn\n5Evd/QTVTJKPxOYus3WRJGvqw09RAqf8XI5iVOTqVv21SyjDUEy5xf0CvwuYJ0c7\nXJ+BGJdLwP7cHdGth0Gwfn+PgvjgJZOYrttn/rMQqy5j5wcu8dtpqxcvEyJhevpF\nm4VRGVb9gzIIG1/RHSk5qQRQ1sS/LHZj6ySy21Iq7B0=\n=oGey\n-----END PGP PUBLIC KEY BLOCK-----\n", + "public": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFtDtxkBEAC4XWuphmzqLCvMf86u6uoIAV5iKdcP8N3xlfmMEtu6I5gFE3+8\n4IOCWbAUcXLqhDY8RjId+gWoKIQZC0n9PDuF04cout6+F+nHlfm8Rx760mSPPTNW\nT9Gk3UtnJlMG+V6vPoiZpIb21rVgBg+7BuvVXyAc/nwiiCUeV/AFGwBMf6MKerCj\nmmo+nfcjJAAfep7NZH/YYEpwoQ9lxWjHn+8pQh9MI5FRur9XGv1+o244SaVHM/0w\n17S6AbPco67S8xyFMO5v88y5dUkJSsN72FX+dTS5Scurdl6J/KNvi4fzBZWg2VTu\nsT99OSOWTjXHX5bcR+43E3U+godOOLtzzfS1TIlAP4Xp+DvXMLwk33Vo73AWAv//\n9IVmk1hVOpmA37AEho9SfbIH4rvEi7aYesk2As5VIY47dIPrAPlf2GC6sTEXeVyJ\ncSEz4fWuMrbw4XtXTOHnf2zSwD8AteUtOcj83OXmLMAqtBFoXGv5Y0XakPJt5RYQ\nzZy6P77ULAKsqC154AkzaWGiQ8UmRhK1aHi3ks3PT91XmN8NYHTTgxRJGXI7gzkj\nn582Ix2EzuoJE16r05Z0M01ggwm1Z2ugHP8ningvCBq0cqpzad/bjF2iX4+pi8um\nuK57rky3Ci4hpDfaSR3CCZb7SNqdkFthKlwaGNOUIn6mBjO56MiXWIY2OQARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8VklUQjZYUFlRUzNNQjJIRkVHRjNOWllENUc1\nWk1HTU9BMlZGS0VURUdZWUtEQVk0REZJNTNaSk1OR0NaS1dNRzJTT043WEFVWE5S\nV0VVUk5KMk9WWTRRSlBHR01HTzRURE1NV0ZTUT0+iQI/BBMBCgApBQJbQ7cZAhsv\nBQkB4MqnBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQXFiwg/uZ5dM+sQ/+\nI7WBIFEcLC9PLCl96mmx2ena9jXYgF+QEkjFBkzOuKwcb70N9ViDzbQBYlfLd1y2\ngbfxRd2l4ODi0j3C8eaW8Iyn8518rZICVMzJPJIEr4RbOui2ykCTEy0SVa/XXbw9\nsNn1auyqUwVxI89HGd7K2yfnN5GFVKhrNRS78v07cGau5UKb+ky6WuyJQ8o+VNRM\nsFXVKYxUEUC9EaDoF55mDvxaNd0v2HG+SGVRmnNj64EvRE/o2Fk/vAozw4gbfL2s\nRyZ8Yl/3NK8bcea8fD7eRwfkVIyRsON8J6XrYmkimrCzi9a+XUH0Zg4YTmXo5COU\nv+poxkdtRxHq1stKYjngOhEnfOfsRf0KHO+yt1RgLs7yS53tNu1P2fQj4ND4yGVo\nHPA772x9Khc9ycM3RItW6JQEJKyoRz9KeTVERni+J0j8MGcGRx+0rLr6dpjrbdQY\nKHK/7i17F2yP7kpG4dSqHb1dRw1x5rBng69kEgaEum22oE69w5oiYGrMihSQtHCw\nzHf9ToOeMiJ5VBrl8obaAJUH+UoQxQD1LSiK1TNlNTA2Q+4z5AqCY3biLXpVFZdO\nlOrfoMRsXGYgxOWYJ7rhHk5zJlkU4pRiywcoSsAQ/mQj8D3Ar0mIqeXoExjseGS6\nAI08meR/2HO1G9XycrBcZfMMkHsnigD2InUdDCCxzlA=\n=HqmY\n-----END PGP PUBLIC KEY BLOCK-----\n", "type": "PGP" }, - "last_updated": "Sat, 07 Jul 2018 00:22:14 GMT", + "last_updated": "2018-07-09T19:27:20.293592Z", "number_of_documents": 0, "number_of_messages": 2, - "remove_star_url": "/api/v1/sources/VY3FJLGRS2DTCEMBYHQ7DU5SM4EBZQZOIASPUEN2IPOLGLZKSATQJWPCRYRP77ELOFC7NJXI66X3IPJPHL6WAQ4ZJEA3NFB3GCXRXWY%3D/remove_star", - "reply_url": "/api/v1/sources/VY3FJLGRS2DTCEMBYHQ7DU5SM4EBZQZOIASPUEN2IPOLGLZKSATQJWPCRYRP77ELOFC7NJXI66X3IPJPHL6WAQ4ZJEA3NFB3GCXRXWY%3D/reply", + "remove_star_url": "/api/v1/sources/VITB6XPYQS3MB2HFEGF3NZYD5G5ZMGMOA2VFKETEGYYKDAY4DFI53ZJMNGCZKWMG2SON7XAUXNRWEURNJ2OVY4QJPGGMGO4TDMMWFSQ%3D/remove_star", + "reply_url": "/api/v1/sources/VITB6XPYQS3MB2HFEGF3NZYD5G5ZMGMOA2VFKETEGYYKDAY4DFI53ZJMNGCZKWMG2SON7XAUXNRWEURNJ2OVY4QJPGGMGO4TDMMWFSQ%3D/reply", "source_id": 2, - "submissions_url": "/api/v1/sources/VY3FJLGRS2DTCEMBYHQ7DU5SM4EBZQZOIASPUEN2IPOLGLZKSATQJWPCRYRP77ELOFC7NJXI66X3IPJPHL6WAQ4ZJEA3NFB3GCXRXWY%3D/submissions", - "url": "/api/v1/sources/VY3FJLGRS2DTCEMBYHQ7DU5SM4EBZQZOIASPUEN2IPOLGLZKSATQJWPCRYRP77ELOFC7NJXI66X3IPJPHL6WAQ4ZJEA3NFB3GCXRXWY%3D" + "submissions_url": "/api/v1/sources/VITB6XPYQS3MB2HFEGF3NZYD5G5ZMGMOA2VFKETEGYYKDAY4DFI53ZJMNGCZKWMG2SON7XAUXNRWEURNJ2OVY4QJPGGMGO4TDMMWFSQ%3D/submissions", + "url": "/api/v1/sources/VITB6XPYQS3MB2HFEGF3NZYD5G5ZMGMOA2VFKETEGYYKDAY4DFI53ZJMNGCZKWMG2SON7XAUXNRWEURNJ2OVY4QJPGGMGO4TDMMWFSQ%3D" } ] } @@ -173,24 +173,24 @@ Response 200 (application/json): .. code:: sh { - "add_star_url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D/add_star", - "filesystem_id": "S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA=", - "interaction_count": 2, - "is_flagged": false, - "is_starred": true, - "journalist_designation": "fifty-five sorter", - "key": { - "public": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFtAB7UBEAC7xcsW+IgHTVgoYOLjNLWPPySqqClbWmU3SruesHPmgt9X1JnN\nHHHVjAofFPkuUHVHqTPZcnG8YTKEu8mcLQp9nDu6rTFZka2XEhAJ39b9piwmIX7/\nPObgBS0DgfMd4aMb4tQWOTH+QzpPHYZuqDFaXnZ4cppVi5x1lxU+tCqbWSRUOpES\nrupCO5Wh5PJtULUZdcEhe38aST7JCnl/FZWwlh4+uQLXqFUhV5mphGwYk+DIz/8c\nhDsf0InXTiZcivHJb9/kefFwsy1nWSXBs8Cu/T5br0iTl9OFUpuXirlMlzUV1PkM\n182RJvtb5lO8YycEYyHO+26vND9sQIzbSMkMkaUkxe8jD0DCf2Jo5CpEQQ76ft8W\ngsPl9Wuts9gH0p/xdaUZCOprLH7A0Eyp4LDrpVMqshUqmQvNuF1UiKhDqjOzA123\ncrvJZIjD1IxH7FGuZCDGjxAhvTfvio5MF2MLcKIsM5OK94Gor46rduxwitHahPUY\n1FgVTMM1PK3cGoHn+kQ42fk1wpHg8IQH0oKM4VYzOy7oWIoLALFbEA2ODW/MiDMC\n+RdkYwTaw+KuhUUt0DRPTOu8ZY8Id1lH/8b5UdKuPs4rg7RK0wtzszMCDmkK3kpe\nLYidO9mTf8ldzMVToHMQRgUAZ0jkPovtx2QHgwayHV5K9E8kfjjitrjZzQARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8UzdCQkZMNExWRkVXMjZHWFA2QlFFN1pKU0tF\nQzNXSUZGQkNVWE1WTTNWRUVTQzNZNFhETVlaU1Q0VURYWVhIRktRWUQ3SVZDNUFH\nM1ZMRVlINUJMTzc0SDJWT1M0MlJLM1U1T0NIQT0+iQI/BBMBCgApBQJbQAe1Ahsv\nBQkB4dcLBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQIci5eVVVBoiQchAA\nuXET3PAkrDAQ3i4sLRkEQ42gqgmUX+QgYyIYzfQm6QpQRHovHY0HutYY2uuqM+vc\nKU+bEPWTD3y2p0bcfO7xyGBq35gnrkV7aC9edRO6Cyz4WPYhiSsjyQ5WXbHhDB5n\nI9RccxVFxTnlVet+TQFU8rX3djkKUI37Pq1O7HwHfPA6rrnR2Y6/OhS0KpWgVWow\nCt1lZoYro2GB5cghkdFbOsvRdoZWQMzYm2BH5EPoBFX+h1i4JPjZZwlDYsi79GqH\nG/KYY3BGqrgk/7Z/Arc45hw3Qo/R8L1xlj24Yyx9jOHQUR+TuUqrIMvXr0nGemU3\ntUy5FzqJH/wMGWKqvryuq4jOZYykZrmv3ogS1aiZiwYBkr4gY1KRjwb1Z3Hh4gyE\nu6VJXZ1BX2mqm0WOamIyqwG9pyvPdDE1EbjUAqdGZiXYIVznsc1xqhOLSAZF2lrO\nfMORxu8O6vYzJWGGnKnu7eC3Fw/nzyqkCwA9Q2Dmwd6brZNhu8cQKP98+HkIzVja\ndxTOZn8AARbVpzxbc0L5k8yyoqwon8OohbU2+K7OZG9uk/1DcSBCLyWk++yYnSng\n/GhncG1RRU9NY6vFs2AB/nxT+JKi4sUG7I890qh9PrRXwfKtHCcIMw5mc26aEnnq\nFEHSg9czun/Aw2qwuJf5EsveBUAWPSBr9nzjdvbERjI=\n=2Ie1\n-----END PGP PUBLIC KEY BLOCK-----\n", - "type": "PGP" - }, - "last_updated": "Sat, 07 Jul 2018 00:22:12 GMT", - "number_of_documents": 0, - "number_of_messages": 2, - "remove_star_url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D/remove_star", - "reply_url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D/reply", - "source_id": 1, - "submissions_url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D/submissions", - "url": "/api/v1/sources/S7BBFL4LVFEW26GXP6BQE7ZJSKEC3WIFFBCUXMVM3VEESC3Y4XDMYZST4UDXYXHFKQYD7IVC5AG3VLEYH5BLO74H2VOS42RK3U5OCHA%3D" + "add_star_url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D/add_star", + "filesystem_id": "6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY=", + "interaction_count": 2, + "is_flagged": false, + "is_starred": false, + "journalist_designation": "tight-fitting horsetail", + "key": { + "public": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFtDtxcBEACaduNhYLXGe3brGRpSFeIe7j6hBVGCfDjfRV0KL2u8VUPIfKIZ\n4kkCtqdfTyTObSPxaTvd20LF+ENI9konXQAf2pBGxFBLRHx0cqwSlHvc6OjCJuXI\nmG94tPThMrw5xLgvhih8/PzdUvsC7vswMp5uAK5jIVam25pXJgjCtivEGVFars0q\n4H5ti3r3GHKhHA7ictjBesTDOiRT0NkCDPDjxv2V+AlNPjfFzf5lPw1zSFVZ5A52\n1OzgadqTZfj+/aZQcQkUA8omoTlcSJI4Mf/Dvn78j4A9bJO643U54rb8Nknnm2u8\niA4RTiGo0uTifZ1Q17tDJBlRTgheH4zrx8LJzEKY1RJDQt2K5RcHsU41TdbDVtbO\nRP6RX/xYRgNzyhUue3Vn3LtMzjmkbti3tiOtIAqUMgKuA6KTNY1uViDgF0hcgi6H\nzIWsoYBZx7RJhK5nEowmddTbN+Fp8gOoUhbCyKFo+f7W6dgVDl8KKJbUapaZpMnr\nk5ldS768Q2KqArSarZTCkUPSYHMvqBGP7ZR1l0HUY4qL1WGtibq3fTE/GPdyadc7\n98slu5/30prXgsV4/mTwWvBZQlixNSM0Rdw69sannDvtRfnH2ocF4oQKOf2htwQ1\nbLvknlOXvXZEy4ctu0FXoZUFjgXHPU5y7+XaxCfspfNSFB+xhzs8FWK9ywARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8NkNIQUQ3NFlVV1RZQk5FWVU0RFlSNlUyTExO\nWkdaR0hBWkFCNU82UjVXSEQyRFVGRlpTU05LS0lETk1BV1FXSUszQUFLWUo0NzdR\nRUc2UFJJRDVDUDJSUUoyU1dDUDNJTlBVNlhHWT0+iQI/BBMBCgApBQJbQ7cXAhsv\nBQkB4MqpBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQcNhgsP2ye/LjVw//\nXe/iXqAoJMc0o2x+/1Z0xyHP/uqy2nJ/ClVibhvwwDUoDGZb3l+1KZGqF9irdhOU\nXAE/N0taKG6LSAckW9I+2nXpUSNH4iXv8uwzW6VsNAY+BSmgRnS/KLsSr6DDFHek\n5zs6gftUYQoTOdpL3CxczGDnh2tJeJmGJcobAiCj8jArlLZhtK2sYHKEBDGW67rT\nskOTuRtRNYCDiOo/0WycD4AKtlZNCI9Az4Sn5Zq9ODlzwBKx7j2CdykKXeTSxqdd\nGZQc4+CD6xempUp2SKsacIhoQKfAw6q8L6pxcc3AJDtImCQJ7qNrEcLQCafQpLhx\nObXeDPNruxLHL+70rpIUs7bj5+ChZUwcfCzuT3bEqpKHCq+8vfpHJVgopUI1gg3G\nr88U6REdXobAQqh5AN3AFYFdg9P4XiFIpAnp7vCWkGGGULEQ0vnTZcASSdbj+MRI\n8v1qk0lZOMOn9JYYc3dePq5okZhfMqPOubEwaji8FDTOmhWnQiigIx8SS/XhQonY\nxJ14xgRoWCqwdBjrrjmJf+OScfJBZvFchf7mwkPkOUrdHCgUkWzGaUI4TLmh9xSk\nTy8fScG/U4JVlH0V8/xY01DOXvGRo3DAEn3ptm+j48fI1coH7jy0n9pQW4r2BNqX\n5MokpDpo3g5AaQr54IV1KBcetYBy301GxIytGaYThn8=\n=b/Mq\n-----END PGP PUBLIC KEY BLOCK-----\n", + "type": "PGP" + }, + "last_updated": "2018-07-09T19:27:17.879344Z", + "number_of_documents": 0, + "number_of_messages": 2, + "remove_star_url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D/remove_star", + "reply_url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D/reply", + "source_id": 1, + "submissions_url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D/submissions", + "url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D" } Get all submissions associated with a source [``GET``] @@ -460,7 +460,7 @@ Response 200: .. code:: sh { - "is_admin": true, - "last_login": "Fri, 29 Jun 2018 20:13:53 GMT", - "username": "journalist" + "is_admin": true, + "last_login": "2018-07-09T20:29:41.696782Z", + "username": "journalist" } diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 4228e76f52..2b44d2039d 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -81,11 +81,11 @@ def get_token(): try: journalist = Journalist.login(username, passphrase, one_time_code) - token_expiry = datetime.now() + timedelta( + token_expiry = datetime.utcnow() + timedelta( seconds=TOKEN_EXPIRATION_MINS * 60) response = jsonify({'token': journalist.generate_api_token( expiration=TOKEN_EXPIRATION_MINS * 60), - 'expiration': token_expiry.isoformat()}) + 'expiration': token_expiry.isoformat() + 'Z'}) # Update access metadata journalist.last_access = datetime.utcnow() diff --git a/securedrop/models.py b/securedrop/models.py index 289f2760d4..b041fded6e 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -118,7 +118,7 @@ def to_json(self): 'journalist_designation': self.journalist_designation, 'is_flagged': self.flagged, 'is_starred': True if self.star else False, - 'last_updated': self.last_updated, + 'last_updated': self.last_updated.isoformat() + 'Z', 'interaction_count': self.interaction_count, 'key': { 'type': 'PGP', @@ -505,7 +505,7 @@ def validate_api_token_and_get_user(token): def to_json(self): json_user = { 'username': self.username, - 'last_login': self.last_access, + 'last_login': self.last_access.isoformat() + 'Z', 'is_admin': self.is_admin } return json_user From daecdd7a45f18c1ae29d0aedcfbe1460604f0ab7 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 9 Jul 2018 15:32:10 -0700 Subject: [PATCH 58/71] Journalist API: Create Source.uuid column --- securedrop/journalist_app/api.py | 61 +++++------ securedrop/models.py | 27 ++--- securedrop/tests/conftest.py | 4 +- securedrop/tests/test_journalist_api.py | 140 ++++++++++-------------- 4 files changed, 97 insertions(+), 135 deletions(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 2b44d2039d..a40cbd91c1 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -104,61 +104,55 @@ def get_all_sources(): return jsonify( {'sources': [source.to_json() for source in sources]}), 200 - @api.route('/sources/', methods=['GET', 'DELETE']) + @api.route('/sources/', methods=['GET', 'DELETE']) @token_required - def single_source(filesystem_id): + def single_source(uuid): if request.method == 'GET': - source = get_or_404(Source, filesystem_id, - column=Source.filesystem_id) + source = get_or_404(Source, uuid, column=Source.uuid) return jsonify(source.to_json()), 200 elif request.method == 'DELETE': - source = get_or_404(Source, filesystem_id, - column=Source.filesystem_id) + source = get_or_404(Source, uuid, column=Source.uuid) utils.delete_collection(source.filesystem_id) return jsonify({'message': 'Source and submissions deleted'}), 200 - @api.route('/sources//add_star', methods=['POST']) + @api.route('/sources//add_star', methods=['POST']) @token_required - def add_star(filesystem_id): - source = get_or_404(Source, filesystem_id, - column=Source.filesystem_id) + def add_star(uuid): + source = get_or_404(Source, uuid, column=Source.uuid) utils.make_star_true(source.filesystem_id) db.session.commit() return jsonify({'message': 'Star added'}), 201 - @api.route('/sources//remove_star', methods=['DELETE']) + @api.route('/sources//remove_star', methods=['DELETE']) @token_required - def remove_star(filesystem_id): - source = get_or_404(Source, filesystem_id, - column=Source.filesystem_id) + def remove_star(uuid): + source = get_or_404(Source, uuid, column=Source.uuid) utils.make_star_false(source.filesystem_id) db.session.commit() return jsonify({'message': 'Star removed'}), 200 - @api.route('/sources//flag', methods=['POST']) + @api.route('/sources//flag', methods=['POST']) @token_required - def flag(filesystem_id): - source = get_or_404(Source, filesystem_id, - column=Source.filesystem_id) + def flag(uuid): + source = get_or_404(Source, uuid, + column=Source.uuid) source.flagged = True db.session.commit() return jsonify({'message': 'Source flagged for reply'}), 200 - @api.route('/sources//submissions', methods=['GET']) + @api.route('/sources//submissions', methods=['GET']) @token_required - def all_source_submissions(filesystem_id): - source = get_or_404(Source, filesystem_id, - column=Source.filesystem_id) + def all_source_submissions(uuid): + source = get_or_404(Source, uuid, column=Source.uuid) return jsonify( {'submissions': [submission.to_json() for submission in source.submissions]}), 200 - @api.route('/sources//submissions//download', # noqa + @api.route('/sources//submissions//download', # noqa methods=['GET']) @token_required - def download_submission(filesystem_id, submission_id): - source = get_or_404(Source, filesystem_id, - column=Source.filesystem_id) + def download_submission(uuid, submission_id): + source = get_or_404(Source, uuid, column=Source.uuid) submission = get_or_404(Submission, submission_id) # Mark as downloaded @@ -170,26 +164,25 @@ def download_submission(filesystem_id, submission_id): mimetype="application/pgp-encrypted", as_attachment=True) - @api.route('/sources//submissions/', + @api.route('/sources//submissions/', methods=['GET', 'DELETE']) @token_required - def single_submission(filesystem_id, submission_id): + def single_submission(uuid, submission_id): if request.method == 'GET': submission = get_or_404(Submission, submission_id) return jsonify(submission.to_json()), 200 elif request.method == 'DELETE': submission = get_or_404(Submission, submission_id) - source = get_or_404(Source, filesystem_id, - column=Source.filesystem_id) + source = get_or_404(Source, uuid, column=Source.uuid) utils.delete_file(source.filesystem_id, submission.filename, submission) return jsonify({'message': 'Submission deleted'}), 200 - @api.route('/sources//reply', methods=['POST']) + @api.route('/sources//reply', methods=['POST']) @token_required - def post_reply(filesystem_id): - source = get_or_404(Source, filesystem_id, - column=Source.filesystem_id) + def post_reply(uuid): + source = get_or_404(Source, uuid, + column=Source.uuid) if request.json is None: abort(400, 'please send requests in valid JSON') diff --git a/securedrop/models.py b/securedrop/models.py index b041fded6e..5efac63075 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -8,6 +8,7 @@ import qrcode # Using svg because it doesn't require additional dependencies import qrcode.image.svg +import uuid # Find the best implementation available on this platform try: @@ -50,6 +51,8 @@ def get_one_or_else(query, logger, failure_method): class Source(db.Model): __tablename__ = 'sources' id = Column(Integer, primary_key=True) + uuid = Column(String(36), unique=True, nullable=False, + default=str(uuid.uuid4())) filesystem_id = Column(String(96), unique=True) journalist_designation = Column(String(255), nullable=False) flagged = Column(Boolean, default=False) @@ -111,10 +114,8 @@ def to_json(self): docs_msg_count = self.documents_messages_count() json_source = { - 'url': url_for('api.single_source', - filesystem_id=self.filesystem_id), - 'source_id': self.id, - 'filesystem_id': self.filesystem_id, + 'uuid': self.uuid, + 'url': url_for('api.single_source', uuid=self.uuid), 'journalist_designation': self.journalist_designation, 'is_flagged': self.flagged, 'is_starred': True if self.star else False, @@ -127,13 +128,10 @@ def to_json(self): 'number_of_documents': docs_msg_count['documents'], 'number_of_messages': docs_msg_count['messages'], 'submissions_url': url_for('api.all_source_submissions', - filesystem_id=self.filesystem_id), - 'add_star_url': url_for('api.add_star', - filesystem_id=self.filesystem_id), - 'remove_star_url': url_for('api.remove_star', - filesystem_id=self.filesystem_id), - 'reply_url': url_for('api.post_reply', - filesystem_id=self.filesystem_id) + uuid=self.uuid), + 'add_star_url': url_for('api.add_star', uuid=self.uuid), + 'remove_star_url': url_for('api.remove_star', uuid=self.uuid), + 'reply_url': url_for('api.post_reply', uuid=self.uuid) } return json_source @@ -162,17 +160,16 @@ def __repr__(self): def to_json(self): json_submission = { - 'source_url': url_for('api.single_source', - filesystem_id=self.source.filesystem_id), + 'source_url': url_for('api.single_source', uuid=self.source.uuid), 'submission_url': url_for('api.single_submission', - filesystem_id=self.source.filesystem_id, + uuid=self.source.uuid, submission_id=self.id), 'submission_id': self.id, 'filename': self.filename, 'size': self.size, 'is_read': self.downloaded, 'download_url': url_for('api.download_submission', - filesystem_id=self.source.filesystem_id, + uuid=self.source.uuid, submission_id=self.id), } return json_submission diff --git a/securedrop/tests/conftest.py b/securedrop/tests/conftest.py index 0721c18904..238fe54231 100644 --- a/securedrop/tests/conftest.py +++ b/securedrop/tests/conftest.py @@ -163,11 +163,11 @@ def test_admin(journalist_app): def test_source(journalist_app): with journalist_app.app_context(): source, codename = utils.db_helper.init_source() - filesystem_id = source.filesystem_id utils.db_helper.submit(source, 2) return {'source': source, 'codename': codename, - 'filesystem_id': filesystem_id, + 'filesystem_id': source.filesystem_id, + 'uuid': source.uuid, 'submissions': source.submissions} diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 1b0e4fa920..721b3b077f 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -137,17 +137,14 @@ def test_authorized_user_gets_all_sources(journalist_app, test_source, def test_user_without_token_cannot_get_protected_endpoints(journalist_app, test_source): with journalist_app.app_context(): - filesystem_id = test_source['source'].filesystem_id + uuid = test_source['source'].uuid protected_routes = [ url_for('api.get_all_sources'), - url_for('api.single_source', filesystem_id=filesystem_id), - url_for('api.all_source_submissions', - filesystem_id=filesystem_id), - url_for('api.single_submission', - filesystem_id=filesystem_id, + url_for('api.single_source', uuid=uuid), + url_for('api.all_source_submissions', uuid=uuid), + url_for('api.single_submission', uuid=uuid, submission_id=test_source['submissions'][0].id), - url_for('api.download_submission', - filesystem_id=filesystem_id, + url_for('api.download_submission', uuid=uuid, submission_id=test_source['submissions'][0].id), url_for('api.get_all_submissions'), url_for('api.get_current_user') @@ -164,15 +161,12 @@ def test_user_without_token_cannot_get_protected_endpoints(journalist_app, def test_user_without_token_cannot_delete_protected_endpoints(journalist_app, test_source): with journalist_app.app_context(): - filesystem_id = test_source['source'].filesystem_id + uuid = test_source['source'].uuid protected_routes = [ - url_for('api.single_source', - filesystem_id=filesystem_id), - url_for('api.single_submission', - filesystem_id=filesystem_id, + url_for('api.single_source', uuid=uuid), + url_for('api.single_submission', uuid=uuid, submission_id=test_source['submissions'][0].id), - url_for('api.remove_star', - filesystem_id=filesystem_id), + url_for('api.remove_star', uuid=uuid), ] with journalist_app.test_client() as app: @@ -187,13 +181,12 @@ def test_attacker_cannot_create_valid_token_with_none_alg(journalist_app, test_source, test_journo): with journalist_app.test_client() as app: - filesystem_id = test_source['source'].filesystem_id + uuid = test_source['source'].uuid s = TimedJSONWebSignatureSerializer('not the secret key', algorithm_name='none') attacker_token = s.dumps({'id': test_journo['id']}).decode('ascii') - response = app.delete(url_for('api.single_source', - filesystem_id=filesystem_id), + response = app.delete(url_for('api.single_source', uuid=uuid), headers=get_api_headers(attacker_token)) assert response.status_code == 403 @@ -204,7 +197,7 @@ def test_attacker_cannot_use_token_after_admin_deletes(journalist_app, journalist_api_token): with journalist_app.test_client() as app: - filesystem_id = test_source['source'].filesystem_id + uuid = test_source['source'].uuid # In a scenario where an attacker compromises a journalist workstation # the admin should be able to delete the user and their token should @@ -216,8 +209,7 @@ def test_attacker_cannot_use_token_after_admin_deletes(journalist_app, db.session.commit() # Now this token should not be valid. - response = app.delete(url_for('api.single_source', - filesystem_id=filesystem_id), + response = app.delete(url_for('api.single_source', uuid=uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 403 @@ -226,11 +218,11 @@ def test_attacker_cannot_use_token_after_admin_deletes(journalist_app, def test_user_without_token_cannot_post_protected_endpoints(journalist_app, test_source): with journalist_app.app_context(): - filesystem_id = test_source['source'].filesystem_id + uuid = test_source['source'].uuid protected_routes = [ - url_for('api.post_reply', filesystem_id=filesystem_id), - url_for('api.add_star', filesystem_id=filesystem_id), - url_for('api.flag', filesystem_id=filesystem_id) + url_for('api.post_reply', uuid=uuid), + url_for('api.add_star', uuid=uuid), + url_for('api.flag', uuid=uuid) ] with journalist_app.test_client() as app: @@ -253,9 +245,8 @@ def test_api_404(journalist_app, journalist_api_token): def test_trailing_slash_cleanly_404s(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: - filesystem_id = test_source['source'].filesystem_id - response = app.get(url_for('api.single_source', - filesystem_id=filesystem_id) + '/', + uuid = test_source['source'].uuid + response = app.get(url_for('api.single_source', uuid=uuid) + '/', headers=get_api_headers(journalist_api_token)) json_response = json.loads(response.data) @@ -266,22 +257,20 @@ def test_trailing_slash_cleanly_404s(journalist_app, test_source, def test_authorized_user_gets_single_source(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: - filesystem_id = test_source['source'].filesystem_id - response = app.get(url_for('api.single_source', - filesystem_id=filesystem_id), + uuid = test_source['source'].uuid + response = app.get(url_for('api.single_source', uuid=uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 data = json.loads(response.data) - assert data['source_id'] == test_source['source'].id + assert data['uuid'] == test_source['source'].uuid assert 'BEGIN PGP PUBLIC KEY' in data['key']['public'] def test_get_non_existant_source_404s(journalist_app, journalist_api_token): with journalist_app.test_client() as app: - response = app.get(url_for('api.single_source', - filesystem_id=1), + response = app.get(url_for('api.single_source', uuid=1), headers=get_api_headers(journalist_api_token)) assert response.status_code == 404 @@ -290,10 +279,9 @@ def test_get_non_existant_source_404s(journalist_app, journalist_api_token): def test_authorized_user_can_flag_a_source(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: - filesystem_id = test_source['source'].filesystem_id + uuid = test_source['source'].uuid source_id = test_source['source'].id - response = app.post(url_for('api.flag', - filesystem_id=filesystem_id), + response = app.post(url_for('api.flag', uuid=uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 @@ -305,10 +293,9 @@ def test_authorized_user_can_flag_a_source(journalist_app, test_source, def test_authorized_user_can_star_a_source(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: - filesystem_id = test_source['source'].filesystem_id + uuid = test_source['source'].uuid source_id = test_source['source'].id - response = app.post(url_for('api.add_star', - filesystem_id=filesystem_id), + response = app.post(url_for('api.add_star', uuid=uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 201 @@ -321,15 +308,13 @@ def test_authorized_user_can_star_a_source(journalist_app, test_source, def test_authorized_user_can_unstar_a_source(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: - filesystem_id = test_source['source'].filesystem_id + uuid = test_source['source'].uuid source_id = test_source['source'].id - response = app.post(url_for('api.add_star', - filesystem_id=filesystem_id), + response = app.post(url_for('api.add_star', uuid=uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 201 - response = app.delete(url_for('api.remove_star', - filesystem_id=filesystem_id), + response = app.delete(url_for('api.remove_star', uuid=uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 @@ -341,9 +326,8 @@ def test_authorized_user_can_unstar_a_source(journalist_app, test_source, def test_disallowed_methods_produces_405(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: - filesystem_id = test_source['source'].filesystem_id - response = app.delete(url_for('api.add_star', - filesystem_id=filesystem_id), + uuid = test_source['source'].uuid + response = app.delete(url_for('api.add_star', uuid=uuid), headers=get_api_headers(journalist_api_token)) json_response = json.loads(response.data) @@ -371,9 +355,8 @@ def test_authorized_user_can_get_all_submissions(journalist_app, test_source, def test_authorized_user_get_source_submissions(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: - filesystem_id = test_source['source'].filesystem_id - response = app.get(url_for('api.all_source_submissions', - filesystem_id=filesystem_id), + uuid = test_source['source'].uuid + response = app.get(url_for('api.all_source_submissions', uuid=uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 @@ -392,9 +375,8 @@ def test_authorized_user_can_get_single_submission(journalist_app, journalist_api_token): with journalist_app.test_client() as app: submission_id = test_source['source'].submissions[0].id - filesystem_id = test_source['source'].filesystem_id - response = app.get(url_for('api.single_submission', - filesystem_id=filesystem_id, + uuid = test_source['source'].uuid + response = app.get(url_for('api.single_submission', uuid=uuid, submission_id=submission_id), headers=get_api_headers(journalist_api_token)) @@ -415,9 +397,8 @@ def test_authorized_user_can_delete_single_submission(journalist_app, journalist_api_token): with journalist_app.test_client() as app: submission_id = test_source['source'].submissions[0].id - filesystem_id = test_source['source'].filesystem_id - response = app.delete(url_for('api.single_submission', - filesystem_id=filesystem_id, + uuid = test_source['source'].uuid + response = app.delete(url_for('api.single_submission', uuid=uuid, submission_id=submission_id), headers=get_api_headers(journalist_api_token)) @@ -432,9 +413,8 @@ def test_authorized_user_can_delete_source_collection(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: - filesystem_id = test_source['source'].filesystem_id - response = app.delete(url_for('api.single_source', - filesystem_id=filesystem_id), + uuid = test_source['source'].uuid + response = app.delete(url_for('api.single_source', uuid=uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 @@ -448,10 +428,9 @@ def test_authorized_user_can_download_submission(journalist_app, journalist_api_token): with journalist_app.test_client() as app: submission_id = test_source['source'].submissions[0].id - filesystem_id = test_source['source'].filesystem_id + uuid = test_source['source'].uuid - response = app.get(url_for('api.download_submission', - filesystem_id=filesystem_id, + response = app.get(url_for('api.download_submission', uuid=uuid, submission_id=submission_id), headers=get_api_headers(journalist_api_token)) @@ -503,10 +482,9 @@ def test_request_with_auth_header_but_no_token_triggers_403(journalist_app): def test_unencrypted_replies_get_rejected(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: - filesystem_id = test_source['source'].filesystem_id + uuid = test_source['source'].uuid reply_content = 'This is a plaintext reply' - response = app.post(url_for('api.post_reply', - filesystem_id=filesystem_id), + response = app.post(url_for('api.post_reply', uuid=uuid), data=json.dumps({'reply': reply_content}), headers=get_api_headers(journalist_api_token)) assert response.status_code == 400 @@ -516,7 +494,7 @@ def test_authorized_user_can_add_reply(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: source_id = test_source['source'].id - filesystem_id = test_source['source'].filesystem_id + uuid = test_source['source'].uuid # First we must encrypt the reply, or it will get rejected # by the server. @@ -525,8 +503,7 @@ def test_authorized_user_can_add_reply(journalist_app, journalist_api_token, reply_content = current_app.crypto_util.gpg.encrypt( 'This is a plaintext reply', source_key).data - response = app.post(url_for('api.post_reply', - filesystem_id=filesystem_id), + response = app.post(url_for('api.post_reply', uuid=uuid), data=json.dumps({'reply': reply_content}), headers=get_api_headers(journalist_api_token)) assert response.status_code == 201 @@ -555,9 +532,8 @@ def test_authorized_user_can_add_reply(journalist_app, journalist_api_token, def test_reply_without_content_400(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: - filesystem_id = test_source['source'].filesystem_id - response = app.post(url_for('api.post_reply', - filesystem_id=filesystem_id), + uuid = test_source['source'].uuid + response = app.post(url_for('api.post_reply', uuid=uuid), data=json.dumps({'reply': ''}), headers=get_api_headers(journalist_api_token)) assert response.status_code == 400 @@ -566,9 +542,8 @@ def test_reply_without_content_400(journalist_app, journalist_api_token, def test_reply_without_reply_field_400(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: - filesystem_id = test_source['source'].filesystem_id - response = app.post(url_for('api.post_reply', - filesystem_id=filesystem_id), + uuid = test_source['source'].uuid + response = app.post(url_for('api.post_reply', uuid=uuid), data=json.dumps({'other': 'stuff'}), headers=get_api_headers(journalist_api_token)) assert response.status_code == 400 @@ -577,9 +552,8 @@ def test_reply_without_reply_field_400(journalist_app, journalist_api_token, def test_reply_without_json_400(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: - filesystem_id = test_source['source'].filesystem_id - response = app.post(url_for('api.post_reply', - filesystem_id=filesystem_id), + uuid = test_source['source'].uuid + response = app.post(url_for('api.post_reply', uuid=uuid), data='invalid', headers=get_api_headers(journalist_api_token)) assert response.status_code == 400 @@ -588,9 +562,8 @@ def test_reply_without_json_400(journalist_app, journalist_api_token, def test_reply_with_valid_curly_json_400(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: - filesystem_id = test_source['source'].filesystem_id - response = app.post(url_for('api.post_reply', - filesystem_id=filesystem_id), + uuid = test_source['source'].uuid + response = app.post(url_for('api.post_reply', uuid=uuid), data='{}', headers=get_api_headers(journalist_api_token)) assert response.status_code == 400 @@ -602,9 +575,8 @@ def test_reply_with_valid_curly_json_400(journalist_app, journalist_api_token, def test_reply_with_valid_square_json_400(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: - filesystem_id = test_source['source'].filesystem_id - response = app.post(url_for('api.post_reply', - filesystem_id=filesystem_id), + uuid = test_source['source'].uuid + response = app.post(url_for('api.post_reply', uuid=uuid), data='[]', headers=get_api_headers(journalist_api_token)) assert response.status_code == 400 From b6d239e69fb54380d2c449405aebedd000979141 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 9 Jul 2018 15:32:52 -0700 Subject: [PATCH 59/71] Alembic: Support batch operations for database migrations This is a workaround for some unimplemented features (ALTER) in SQLite, e.g. the ability to modify constraints on a column after it has been created, see: https://stackoverflow.com/questions/30378233/sqlite-lack-of-alter-support-alembic-migration-failing-because-of-this-solutio http://alembic.zzzcomputing.com/en/latest/batch.html#batch-mode-with-autogenerate https://github.com/miguelgrinberg/Flask-Migrate/issues/61 --- securedrop/alembic/env.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/securedrop/alembic/env.py b/securedrop/alembic/env.py index b7dcd293c3..8d08c755f4 100644 --- a/securedrop/alembic/env.py +++ b/securedrop/alembic/env.py @@ -68,7 +68,8 @@ def run_migrations_online(): with connectable.connect() as connection: context.configure( connection=connection, - target_metadata=target_metadata + target_metadata=target_metadata, + render_as_batch=True ) with context.begin_transaction(): From 8f385a9ef3179f395513c6364ce33bbdcfdacd07 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 9 Jul 2018 17:33:47 -0700 Subject: [PATCH 60/71] Bugfix: Upgrade test should be on old database, then load data Just an off by one error --- securedrop/tests/test_alembic.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/securedrop/tests/test_alembic.py b/securedrop/tests/test_alembic.py index 64ff9b2873..8fc9bda44f 100644 --- a/securedrop/tests/test_alembic.py +++ b/securedrop/tests/test_alembic.py @@ -137,9 +137,9 @@ def test_upgrade_with_data(alembic_config, config, migration): # Degenerate case where there is no data for the first migration return - # Upgrade to one migration before the target - target = migrations[-1] - upgrade(alembic_config, target) + # Upgrade to one migration before the target stored in `migration` + last_migration = migrations[-2] + upgrade(alembic_config, last_migration) # Dynamic module import mod_name = 'tests.migrations.migration_{}'.format(migration) From 664be6e613853e621fd3ec4e4f0149fe450f50eb Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 9 Jul 2018 17:57:16 -0700 Subject: [PATCH 61/71] API docs: Update for UUID change --- docs/development/journalist_api.rst | 273 ++++++++++++++-------------- 1 file changed, 135 insertions(+), 138 deletions(-) diff --git a/docs/development/journalist_api.rst b/docs/development/journalist_api.rst index 9a55897992..162d37296f 100644 --- a/docs/development/journalist_api.rst +++ b/docs/development/journalist_api.rst @@ -117,52 +117,50 @@ Response 200 (application/json): .. code:: sh { - "sources": [ - { - "add_star_url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D/add_star", - "filesystem_id": "6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY=", - "interaction_count": 2, - "is_flagged": false, - "is_starred": false, - "journalist_designation": "tight-fitting horsetail", - "key": { - "public": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFtDtxcBEACaduNhYLXGe3brGRpSFeIe7j6hBVGCfDjfRV0KL2u8VUPIfKIZ\n4kkCtqdfTyTObSPxaTvd20LF+ENI9konXQAf2pBGxFBLRHx0cqwSlHvc6OjCJuXI\nmG94tPThMrw5xLgvhih8/PzdUvsC7vswMp5uAK5jIVam25pXJgjCtivEGVFars0q\n4H5ti3r3GHKhHA7ictjBesTDOiRT0NkCDPDjxv2V+AlNPjfFzf5lPw1zSFVZ5A52\n1OzgadqTZfj+/aZQcQkUA8omoTlcSJI4Mf/Dvn78j4A9bJO643U54rb8Nknnm2u8\niA4RTiGo0uTifZ1Q17tDJBlRTgheH4zrx8LJzEKY1RJDQt2K5RcHsU41TdbDVtbO\nRP6RX/xYRgNzyhUue3Vn3LtMzjmkbti3tiOtIAqUMgKuA6KTNY1uViDgF0hcgi6H\nzIWsoYBZx7RJhK5nEowmddTbN+Fp8gOoUhbCyKFo+f7W6dgVDl8KKJbUapaZpMnr\nk5ldS768Q2KqArSarZTCkUPSYHMvqBGP7ZR1l0HUY4qL1WGtibq3fTE/GPdyadc7\n98slu5/30prXgsV4/mTwWvBZQlixNSM0Rdw69sannDvtRfnH2ocF4oQKOf2htwQ1\nbLvknlOXvXZEy4ctu0FXoZUFjgXHPU5y7+XaxCfspfNSFB+xhzs8FWK9ywARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8NkNIQUQ3NFlVV1RZQk5FWVU0RFlSNlUyTExO\nWkdaR0hBWkFCNU82UjVXSEQyRFVGRlpTU05LS0lETk1BV1FXSUszQUFLWUo0NzdR\nRUc2UFJJRDVDUDJSUUoyU1dDUDNJTlBVNlhHWT0+iQI/BBMBCgApBQJbQ7cXAhsv\nBQkB4MqpBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQcNhgsP2ye/LjVw//\nXe/iXqAoJMc0o2x+/1Z0xyHP/uqy2nJ/ClVibhvwwDUoDGZb3l+1KZGqF9irdhOU\nXAE/N0taKG6LSAckW9I+2nXpUSNH4iXv8uwzW6VsNAY+BSmgRnS/KLsSr6DDFHek\n5zs6gftUYQoTOdpL3CxczGDnh2tJeJmGJcobAiCj8jArlLZhtK2sYHKEBDGW67rT\nskOTuRtRNYCDiOo/0WycD4AKtlZNCI9Az4Sn5Zq9ODlzwBKx7j2CdykKXeTSxqdd\nGZQc4+CD6xempUp2SKsacIhoQKfAw6q8L6pxcc3AJDtImCQJ7qNrEcLQCafQpLhx\nObXeDPNruxLHL+70rpIUs7bj5+ChZUwcfCzuT3bEqpKHCq+8vfpHJVgopUI1gg3G\nr88U6REdXobAQqh5AN3AFYFdg9P4XiFIpAnp7vCWkGGGULEQ0vnTZcASSdbj+MRI\n8v1qk0lZOMOn9JYYc3dePq5okZhfMqPOubEwaji8FDTOmhWnQiigIx8SS/XhQonY\nxJ14xgRoWCqwdBjrrjmJf+OScfJBZvFchf7mwkPkOUrdHCgUkWzGaUI4TLmh9xSk\nTy8fScG/U4JVlH0V8/xY01DOXvGRo3DAEn3ptm+j48fI1coH7jy0n9pQW4r2BNqX\n5MokpDpo3g5AaQr54IV1KBcetYBy301GxIytGaYThn8=\n=b/Mq\n-----END PGP PUBLIC KEY BLOCK-----\n", - "type": "PGP" - }, - "last_updated": "2018-07-09T19:27:17.879344Z", - "number_of_documents": 0, - "number_of_messages": 2, - "remove_star_url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D/remove_star", - "reply_url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D/reply", - "source_id": 1, - "submissions_url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D/submissions", - "url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D" - }, - { - "add_star_url": "/api/v1/sources/VITB6XPYQS3MB2HFEGF3NZYD5G5ZMGMOA2VFKETEGYYKDAY4DFI53ZJMNGCZKWMG2SON7XAUXNRWEURNJ2OVY4QJPGGMGO4TDMMWFSQ%3D/add_star", - "filesystem_id": "VITB6XPYQS3MB2HFEGF3NZYD5G5ZMGMOA2VFKETEGYYKDAY4DFI53ZJMNGCZKWMG2SON7XAUXNRWEURNJ2OVY4QJPGGMGO4TDMMWFSQ=", - "interaction_count": 2, - "is_flagged": false, - "is_starred": false, - "journalist_designation": "existential irreverence", - "key": { - "public": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFtDtxkBEAC4XWuphmzqLCvMf86u6uoIAV5iKdcP8N3xlfmMEtu6I5gFE3+8\n4IOCWbAUcXLqhDY8RjId+gWoKIQZC0n9PDuF04cout6+F+nHlfm8Rx760mSPPTNW\nT9Gk3UtnJlMG+V6vPoiZpIb21rVgBg+7BuvVXyAc/nwiiCUeV/AFGwBMf6MKerCj\nmmo+nfcjJAAfep7NZH/YYEpwoQ9lxWjHn+8pQh9MI5FRur9XGv1+o244SaVHM/0w\n17S6AbPco67S8xyFMO5v88y5dUkJSsN72FX+dTS5Scurdl6J/KNvi4fzBZWg2VTu\nsT99OSOWTjXHX5bcR+43E3U+godOOLtzzfS1TIlAP4Xp+DvXMLwk33Vo73AWAv//\n9IVmk1hVOpmA37AEho9SfbIH4rvEi7aYesk2As5VIY47dIPrAPlf2GC6sTEXeVyJ\ncSEz4fWuMrbw4XtXTOHnf2zSwD8AteUtOcj83OXmLMAqtBFoXGv5Y0XakPJt5RYQ\nzZy6P77ULAKsqC154AkzaWGiQ8UmRhK1aHi3ks3PT91XmN8NYHTTgxRJGXI7gzkj\nn582Ix2EzuoJE16r05Z0M01ggwm1Z2ugHP8ningvCBq0cqpzad/bjF2iX4+pi8um\nuK57rky3Ci4hpDfaSR3CCZb7SNqdkFthKlwaGNOUIn6mBjO56MiXWIY2OQARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8VklUQjZYUFlRUzNNQjJIRkVHRjNOWllENUc1\nWk1HTU9BMlZGS0VURUdZWUtEQVk0REZJNTNaSk1OR0NaS1dNRzJTT043WEFVWE5S\nV0VVUk5KMk9WWTRRSlBHR01HTzRURE1NV0ZTUT0+iQI/BBMBCgApBQJbQ7cZAhsv\nBQkB4MqnBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQXFiwg/uZ5dM+sQ/+\nI7WBIFEcLC9PLCl96mmx2ena9jXYgF+QEkjFBkzOuKwcb70N9ViDzbQBYlfLd1y2\ngbfxRd2l4ODi0j3C8eaW8Iyn8518rZICVMzJPJIEr4RbOui2ykCTEy0SVa/XXbw9\nsNn1auyqUwVxI89HGd7K2yfnN5GFVKhrNRS78v07cGau5UKb+ky6WuyJQ8o+VNRM\nsFXVKYxUEUC9EaDoF55mDvxaNd0v2HG+SGVRmnNj64EvRE/o2Fk/vAozw4gbfL2s\nRyZ8Yl/3NK8bcea8fD7eRwfkVIyRsON8J6XrYmkimrCzi9a+XUH0Zg4YTmXo5COU\nv+poxkdtRxHq1stKYjngOhEnfOfsRf0KHO+yt1RgLs7yS53tNu1P2fQj4ND4yGVo\nHPA772x9Khc9ycM3RItW6JQEJKyoRz9KeTVERni+J0j8MGcGRx+0rLr6dpjrbdQY\nKHK/7i17F2yP7kpG4dSqHb1dRw1x5rBng69kEgaEum22oE69w5oiYGrMihSQtHCw\nzHf9ToOeMiJ5VBrl8obaAJUH+UoQxQD1LSiK1TNlNTA2Q+4z5AqCY3biLXpVFZdO\nlOrfoMRsXGYgxOWYJ7rhHk5zJlkU4pRiywcoSsAQ/mQj8D3Ar0mIqeXoExjseGS6\nAI08meR/2HO1G9XycrBcZfMMkHsnigD2InUdDCCxzlA=\n=HqmY\n-----END PGP PUBLIC KEY BLOCK-----\n", - "type": "PGP" - }, - "last_updated": "2018-07-09T19:27:20.293592Z", - "number_of_documents": 0, - "number_of_messages": 2, - "remove_star_url": "/api/v1/sources/VITB6XPYQS3MB2HFEGF3NZYD5G5ZMGMOA2VFKETEGYYKDAY4DFI53ZJMNGCZKWMG2SON7XAUXNRWEURNJ2OVY4QJPGGMGO4TDMMWFSQ%3D/remove_star", - "reply_url": "/api/v1/sources/VITB6XPYQS3MB2HFEGF3NZYD5G5ZMGMOA2VFKETEGYYKDAY4DFI53ZJMNGCZKWMG2SON7XAUXNRWEURNJ2OVY4QJPGGMGO4TDMMWFSQ%3D/reply", - "source_id": 2, - "submissions_url": "/api/v1/sources/VITB6XPYQS3MB2HFEGF3NZYD5G5ZMGMOA2VFKETEGYYKDAY4DFI53ZJMNGCZKWMG2SON7XAUXNRWEURNJ2OVY4QJPGGMGO4TDMMWFSQ%3D/submissions", - "url": "/api/v1/sources/VITB6XPYQS3MB2HFEGF3NZYD5G5ZMGMOA2VFKETEGYYKDAY4DFI53ZJMNGCZKWMG2SON7XAUXNRWEURNJ2OVY4QJPGGMGO4TDMMWFSQ%3D" - } - ] + "sources": [ + { + "add_star_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/add_star", + "interaction_count": 2, + "is_flagged": false, + "is_starred": false, + "journalist_designation": "validated benefactress", + "key": { + "public": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFtEA0YBEAChcaDWfnLvMNDypxF+YhNI/P0wYw7+kGGTCAr+pChzV1I3ZEBO\nOz3NU4G5+MYHstD3m4Cdcwdvo+S6E66B4h/9xWWtJLzBMmRNBrCpfny8id1QyNsd\n2PPYk2Dt6Xs9RZaHO3sd8nXVx07FwYmMzNa3UlRg6kb0EUwzNDOW0jaramutp1c0\noTHiEiHJ9wQLNnU55kIXBg6XTNpquCj8O6Vpnsgr0HCC+Fr9hno8u58seYUnyhaN\n3PNE7d96H3O1MNGk0L10vt1u/449DoYFeWR1GnhssfAlVjhizf1sflNXCybjACqK\ngVMsKnYpDWzIXOPF7jNW7jn/N3EpGhq1pjjAJ4LNPXnsTgCmkA5okcPSPIhUH2gN\n6WVtPryGQ9iV5cWgL2KDq35VoZ+6+raANAeE23yAnJW9c7HLRckeB429GNAu1TKR\nkNmDe6zmuhwM2VA+JDN23gFjl7uMgN9bVz6pAyA+0eUQG6Ak3fJmCAGdNIx0/Htq\nRgUwElpHDbrp8kzmadfdWVwq/Tf373FE5TFL2mQ7EVI8xQ4HWvhWRFjpQKWRzBsg\nBLXWzr2C6coQywNLUvJ0JEkm/Uihd5341JoRuotUAY8pwA3CWUTSSi/7yBBAJzRk\nNy7XivylH084DM2/EJaq5gNbHJ7jA31YymwQdw3OmIqX4K07zS2AdGX20QARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8TTdOUEo3RVJNQU9KREhPSUpaUTIzVEdTTlFR\nR0JIQTNEVUJMUjdFUDQ1R0pKWVJGVkpSTFVSSDI0NjRBS1dMTjYyVFNSWFNJMzJE\nNkQzTjdXVTRFWTU3SzdBRkpZVFpHR0NUQkFKUT0+iQI/BBMBCgApBQJbRANGAhsv\nBQkB4c/6BwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQDDX++nndxld+CA/9\nGoG3Xm3e2pyW+itxKC/gOJiK/PXk/nrpNXF5d1b1TEbkMMmMy2Dw4YC/7btr3Q0D\nEUg5qXiIO+Tw9KNS1udTVJggG+jWehlgOMb0+Z7JUawPCwAFjU17BRdRVDv39Y5G\nGJSM68/e8n5HXLNx1ABFlm0qfGQQw+/anwwxCnJb5KgSZ64mZiYtjVNiaqrtxxB7\nXu6AOsTlWgzT5rkwrq6gZsdG53gRYQiaVLS8BDKT4WD45iYKR5nn0BvPN6/L+4UG\nQj0l2lbAuQGMuMVKCeRYIJEDzTeqHzxuqkrr79pBZz1rNSNWYmaYo5V7ZH1VIl5y\n+jf1mEbvhNQUoy2HCoTUGPJjpgg7LyN7S6eZH/J5Q8gHD4s+rnQbzJHwD3u5y3L+\nDtz3trQs6K6CcqsyYBCS0oH3DSYO9SJiBJqgoSKKs8/YtqWupDXUFCjcYgdxDEmR\nLw+Ovd0wEbs7JoMcpRtx3LHgpL6ICFZqFvA3IyTo6OCa8ZCCnvtkLvlinUg0TGTc\nmvThHu/1jbDZjAPWRiuoEcHz5XyFSrCzkXKvXEDqlsK1WADNWZlznfBhu9EgciHP\nlOAJrKulOC4TaRmHP+K5MFowmwB1IY9yErhvAobTnZn7sXqc2AY5cTPfphvuHJwR\nFwtb1yZ6TEBSiLywZguTHurVeIyKW4C2jSlqyV1BnH8=\n=/Wxo\n-----END PGP PUBLIC KEY BLOCK-----\n", + "type": "PGP" + }, + "last_updated": "2018-07-10T00:52:21.157409Z", + "number_of_documents": 0, + "number_of_messages": 2, + "remove_star_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/remove_star", + "reply_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/reply", + "submissions_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions", + "url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a", + "uuid": "9b6df7c9-a6b1-461d-91f0-5b715fc7a47a" + }, + { + "add_star_url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce/add_star", + "interaction_count": 2, + "is_flagged": false, + "is_starred": false, + "journalist_designation": "navigational firearm", + "key": { + "public": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFtEA0sBEACsJK+UPZoemYts+L+4JnhsRXJqixMO2BDJEueiGg2Aq0CEI4pz\nmNq5Xn/ZjHChnh/3AEc/Svv1IpA8RH4cgTfKTzpv5OnEwk6+0FUgr2rhCLzju9At\nrdhE1wFhldSWU4RyB/sC0L20HSP0H6Uj2xsT+gqw06fNvEzHKEpGt9dR6hQxH9Hf\new0z/p8Oov7x5wRRnZbe1VezlAM4L7BsboBUNrLsnKi7BvZFihRrL+CYaSH/XZ1E\n/6aBNPol9zVEeG8A+L21TVvBsjHb76Jr5t9iIl1kd1z3mMgq9cZacal96aONISLU\nv3pdlpY+5lBFLvhiSfFcNNNwMkglKmzRxNVcmxhUMquFpUHlsLxcz177cftkR0qD\nJhyVqeYEWeZgJ8IRFWaRK5NvCCLSJoLtAYcx7IRRBZJ7Y5rGBPH6rjYw75fXhDHq\n+ApL5/iVPkxrKdYfBxQApuYNW0pUpML9GSGpBiF8ri3C11dKIfMjwO6a69YNoJi2\nqiu/7p+BIHLCrdHlYZCHTgrYXlx0uNR9pVry7ioNNekJaoBcXIfsL5n5QiVS9rX+\nNSNsUF+yEB/9OFFywwaHlvMLYBMm1ikiU7DAbxowJxbw7Sh8N/sP1LMiv/2YUHiT\nqUJHBdyuOvaVFhcgrXUKPaX2B/yaTjXl/9u0sSfM9uoGyRQoj+OwtwC7BwARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8SFNNM1FVNTdUQUdHVjNEVkU0UUFFRkxUVkU1\nNEk2T1ZSU0lSSkJOWE5CVTZYWUlYNDRFVzZJNFdHSkY0U1dVS1hKQlhFSTZKQ0NW\nNkREM0ZGM1BZQ1hXM1NYTlJORk5DSERGWVBFST0+iQI/BBMBCgApBQJbRANLAhsv\nBQkB4c/1BwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQ3ZWCdf0oVBq5EQ/9\nEUvasqWfeyidKAcHfXa/mu0ENyeDbDXgJNiZB867v3MaZWUn+5qy+SRcDGev1TBl\nwOzSt7uao6Zrqi9/Lexe07xjLEGRGYolZwOFLP+vlULpsgncen8lpENwrtY9MO3w\nbiobArNhp0kCvn6aiUi8Lb3nl57FpJ9dKfhMmP7evf0DcEvFcsDBoR7LHkMgEHQX\n5WbkvMyO7eoU+S4KrtU8PbR03j3cDv+YvLCJnwJyO79SqbkxafmAKD5KaUnsRTK5\nvoIeDH5dhGOQI0/YpCcCNZJP187rooOwlBL+R2r+LhyjK5YUEH1XKz9z8M6oQirZ\ntG8JbZbxCc19OnhL3SijsGVpqIuENd0VuNA1TLfzlbhJ/AYMBcQgRSU3a0kWRA3+\nNEZ5vEQkWtaL2bxDv2TkJdbS335nCBkuOIJgVMGiy9OjZdT58zEqpMupBWCzA67O\nLdovCyvNErWcs30QUqVRHreIaUMEQBcqtWJAhnfdfXNaQUr3ac0oopEZi30I9uDW\nejVc+ml00nTeg3WLqibjaJkid8QTfwkxx4oJ4WJaCgq/b0UvyBxD04N/ZpJHG2ja\n28uQ8v9rBJgTPR5uZNw4of842u17J6F65x7+phnoy6ayXCV0fwgzjSg85dPUUPIT\ns1CnQxnBjVUbCHELdx2LR7XSmVwkAHBVJ1NALCMiQic=\n=pmcO\n-----END PGP PUBLIC KEY BLOCK-----\n", + "type": "PGP" + }, + "last_updated": "2018-07-10T00:52:25.696391Z", + "number_of_documents": 0, + "number_of_messages": 2, + "remove_star_url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce/remove_star", + "reply_url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce/reply", + "submissions_url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce/submissions", + "url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce", + "uuid": "f086bd03-1c89-49fb-82d5-00084c17b4ce" + } + ] } -Individual Source ``[/sources/]`` ------------------------------------------------- +Individual Source ``[/sources/]`` +--------------------------------------- Requires authentication @@ -173,24 +171,23 @@ Response 200 (application/json): .. code:: sh { - "add_star_url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D/add_star", - "filesystem_id": "6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY=", - "interaction_count": 2, - "is_flagged": false, - "is_starred": false, - "journalist_designation": "tight-fitting horsetail", - "key": { - "public": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFtDtxcBEACaduNhYLXGe3brGRpSFeIe7j6hBVGCfDjfRV0KL2u8VUPIfKIZ\n4kkCtqdfTyTObSPxaTvd20LF+ENI9konXQAf2pBGxFBLRHx0cqwSlHvc6OjCJuXI\nmG94tPThMrw5xLgvhih8/PzdUvsC7vswMp5uAK5jIVam25pXJgjCtivEGVFars0q\n4H5ti3r3GHKhHA7ictjBesTDOiRT0NkCDPDjxv2V+AlNPjfFzf5lPw1zSFVZ5A52\n1OzgadqTZfj+/aZQcQkUA8omoTlcSJI4Mf/Dvn78j4A9bJO643U54rb8Nknnm2u8\niA4RTiGo0uTifZ1Q17tDJBlRTgheH4zrx8LJzEKY1RJDQt2K5RcHsU41TdbDVtbO\nRP6RX/xYRgNzyhUue3Vn3LtMzjmkbti3tiOtIAqUMgKuA6KTNY1uViDgF0hcgi6H\nzIWsoYBZx7RJhK5nEowmddTbN+Fp8gOoUhbCyKFo+f7W6dgVDl8KKJbUapaZpMnr\nk5ldS768Q2KqArSarZTCkUPSYHMvqBGP7ZR1l0HUY4qL1WGtibq3fTE/GPdyadc7\n98slu5/30prXgsV4/mTwWvBZQlixNSM0Rdw69sannDvtRfnH2ocF4oQKOf2htwQ1\nbLvknlOXvXZEy4ctu0FXoZUFjgXHPU5y7+XaxCfspfNSFB+xhzs8FWK9ywARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8NkNIQUQ3NFlVV1RZQk5FWVU0RFlSNlUyTExO\nWkdaR0hBWkFCNU82UjVXSEQyRFVGRlpTU05LS0lETk1BV1FXSUszQUFLWUo0NzdR\nRUc2UFJJRDVDUDJSUUoyU1dDUDNJTlBVNlhHWT0+iQI/BBMBCgApBQJbQ7cXAhsv\nBQkB4MqpBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQcNhgsP2ye/LjVw//\nXe/iXqAoJMc0o2x+/1Z0xyHP/uqy2nJ/ClVibhvwwDUoDGZb3l+1KZGqF9irdhOU\nXAE/N0taKG6LSAckW9I+2nXpUSNH4iXv8uwzW6VsNAY+BSmgRnS/KLsSr6DDFHek\n5zs6gftUYQoTOdpL3CxczGDnh2tJeJmGJcobAiCj8jArlLZhtK2sYHKEBDGW67rT\nskOTuRtRNYCDiOo/0WycD4AKtlZNCI9Az4Sn5Zq9ODlzwBKx7j2CdykKXeTSxqdd\nGZQc4+CD6xempUp2SKsacIhoQKfAw6q8L6pxcc3AJDtImCQJ7qNrEcLQCafQpLhx\nObXeDPNruxLHL+70rpIUs7bj5+ChZUwcfCzuT3bEqpKHCq+8vfpHJVgopUI1gg3G\nr88U6REdXobAQqh5AN3AFYFdg9P4XiFIpAnp7vCWkGGGULEQ0vnTZcASSdbj+MRI\n8v1qk0lZOMOn9JYYc3dePq5okZhfMqPOubEwaji8FDTOmhWnQiigIx8SS/XhQonY\nxJ14xgRoWCqwdBjrrjmJf+OScfJBZvFchf7mwkPkOUrdHCgUkWzGaUI4TLmh9xSk\nTy8fScG/U4JVlH0V8/xY01DOXvGRo3DAEn3ptm+j48fI1coH7jy0n9pQW4r2BNqX\n5MokpDpo3g5AaQr54IV1KBcetYBy301GxIytGaYThn8=\n=b/Mq\n-----END PGP PUBLIC KEY BLOCK-----\n", - "type": "PGP" - }, - "last_updated": "2018-07-09T19:27:17.879344Z", - "number_of_documents": 0, - "number_of_messages": 2, - "remove_star_url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D/remove_star", - "reply_url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D/reply", - "source_id": 1, - "submissions_url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D/submissions", - "url": "/api/v1/sources/6CHAD74YUWTYBNEYU4DYR6U2LLNZGZGHAZAB5O6R5WHD2DUFFZSSNKKIDNMAWQWIK3AAKYJ477QEG6PRID5CP2RQJ2SWCP3INPU6XGY%3D" + "add_star_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/add_star", + "interaction_count": 2, + "is_flagged": false, + "is_starred": false, + "journalist_designation": "validated benefactress", + "key": { + "public": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFtEA0YBEAChcaDWfnLvMNDypxF+YhNI/P0wYw7+kGGTCAr+pChzV1I3ZEBO\nOz3NU4G5+MYHstD3m4Cdcwdvo+S6E66B4h/9xWWtJLzBMmRNBrCpfny8id1QyNsd\n2PPYk2Dt6Xs9RZaHO3sd8nXVx07FwYmMzNa3UlRg6kb0EUwzNDOW0jaramutp1c0\noTHiEiHJ9wQLNnU55kIXBg6XTNpquCj8O6Vpnsgr0HCC+Fr9hno8u58seYUnyhaN\n3PNE7d96H3O1MNGk0L10vt1u/449DoYFeWR1GnhssfAlVjhizf1sflNXCybjACqK\ngVMsKnYpDWzIXOPF7jNW7jn/N3EpGhq1pjjAJ4LNPXnsTgCmkA5okcPSPIhUH2gN\n6WVtPryGQ9iV5cWgL2KDq35VoZ+6+raANAeE23yAnJW9c7HLRckeB429GNAu1TKR\nkNmDe6zmuhwM2VA+JDN23gFjl7uMgN9bVz6pAyA+0eUQG6Ak3fJmCAGdNIx0/Htq\nRgUwElpHDbrp8kzmadfdWVwq/Tf373FE5TFL2mQ7EVI8xQ4HWvhWRFjpQKWRzBsg\nBLXWzr2C6coQywNLUvJ0JEkm/Uihd5341JoRuotUAY8pwA3CWUTSSi/7yBBAJzRk\nNy7XivylH084DM2/EJaq5gNbHJ7jA31YymwQdw3OmIqX4K07zS2AdGX20QARAQAB\ntHxBdXRvZ2VuZXJhdGVkIEtleSA8TTdOUEo3RVJNQU9KREhPSUpaUTIzVEdTTlFR\nR0JIQTNEVUJMUjdFUDQ1R0pKWVJGVkpSTFVSSDI0NjRBS1dMTjYyVFNSWFNJMzJE\nNkQzTjdXVTRFWTU3SzdBRkpZVFpHR0NUQkFKUT0+iQI/BBMBCgApBQJbRANGAhsv\nBQkB4c/6BwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQDDX++nndxld+CA/9\nGoG3Xm3e2pyW+itxKC/gOJiK/PXk/nrpNXF5d1b1TEbkMMmMy2Dw4YC/7btr3Q0D\nEUg5qXiIO+Tw9KNS1udTVJggG+jWehlgOMb0+Z7JUawPCwAFjU17BRdRVDv39Y5G\nGJSM68/e8n5HXLNx1ABFlm0qfGQQw+/anwwxCnJb5KgSZ64mZiYtjVNiaqrtxxB7\nXu6AOsTlWgzT5rkwrq6gZsdG53gRYQiaVLS8BDKT4WD45iYKR5nn0BvPN6/L+4UG\nQj0l2lbAuQGMuMVKCeRYIJEDzTeqHzxuqkrr79pBZz1rNSNWYmaYo5V7ZH1VIl5y\n+jf1mEbvhNQUoy2HCoTUGPJjpgg7LyN7S6eZH/J5Q8gHD4s+rnQbzJHwD3u5y3L+\nDtz3trQs6K6CcqsyYBCS0oH3DSYO9SJiBJqgoSKKs8/YtqWupDXUFCjcYgdxDEmR\nLw+Ovd0wEbs7JoMcpRtx3LHgpL6ICFZqFvA3IyTo6OCa8ZCCnvtkLvlinUg0TGTc\nmvThHu/1jbDZjAPWRiuoEcHz5XyFSrCzkXKvXEDqlsK1WADNWZlznfBhu9EgciHP\nlOAJrKulOC4TaRmHP+K5MFowmwB1IY9yErhvAobTnZn7sXqc2AY5cTPfphvuHJwR\nFwtb1yZ6TEBSiLywZguTHurVeIyKW4C2jSlqyV1BnH8=\n=/Wxo\n-----END PGP PUBLIC KEY BLOCK-----\n", + "type": "PGP" + }, + "last_updated": "2018-07-10T00:52:21.157409Z", + "number_of_documents": 0, + "number_of_messages": 2, + "remove_star_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/remove_star", + "reply_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/reply", + "submissions_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions", + "url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a", + "uuid": "9b6df7c9-a6b1-461d-91f0-5b715fc7a47a" } Get all submissions associated with a source [``GET``] @@ -200,33 +197,33 @@ Requires authentication. .. code:: sh - GET /api/v1/sources//submissions + GET /api/v1/sources//submissions Response 200 (application/json): .. code:: sh { - "submissions": [ - { - "download_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/submissions/3/download/", - "filename": "1-clairvoyant_burdock-msg.gpg", - "is_read": false, - "size": 604, - "source_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/", - "submission_id": 3, - "submission_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/submissions/3/" - }, - { - "download_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/submissions/4/download/", - "filename": "2-clairvoyant_burdock-msg.gpg", - "is_read": false, - "size": 604, - "source_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/", - "submission_id": 4, - "submission_url": "/api/v1/sources/LBICF2DPGI3AMD74HIYXQ7QKQB3MCCKNMQ6Q4VPOL3OYWMIDMYDO6A37KOJCZNT3GVOEMCDHECM4S4OAXGGMZ452SD454A6EADXN3ZI%3D/submissions/4/" - } - ] + "submissions": [ + { + "download_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/1/download", + "filename": "1-validated_benefactress-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a", + "submission_id": 1, + "submission_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/1" + }, + { + "download_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/2/download", + "filename": "2-validated_benefactress-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a", + "submission_id": 2, + "submission_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/2" + } + ] } Get a single submission associated with a source [``GET``] @@ -236,20 +233,20 @@ Requires authentication. .. code:: sh - GET /api/v1/sources//submissions/ + GET /api/v1/sources//submissions/ Response 200 (application/json): .. code:: sh { - "download_url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/submissions/1/download/", - "filename": "1-olfactory_yuppie-msg.gpg", - "is_read": false, - "size": 604, - "source_url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/", - "submission_id": 1, - "submission_url": "/api/v1/sources/44YGZ2R7643TXBA65ZKRO5D6QH26RJ7NVDFMQJVFSMM6WA5W3ZDXNUYKGBTEUYGFCABBUEDLQ7OKS657WKOGUHFLVDLQ75GWTOX4D4A%3D/submissions/1/" + "download_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/1/download", + "filename": "1-validated_benefactress-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a", + "submission_id": 1, + "submission_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/1" } Add a reply to a source [``POST``] @@ -261,7 +258,7 @@ source. .. code:: sh - POST /api/v1/sources//reply + POST /api/v1/sources//reply with the reply in the request body: @@ -296,7 +293,7 @@ Requires authentication. .. code:: sh - DELETE /api/v1/sources//submissions/ + DELETE /api/v1/sources//submissions/ Response 200: @@ -313,7 +310,7 @@ Requires authentication. .. code:: sh - GET /api/v1/sources//submissions//download + GET /api/v1/sources//submissions//download Response 200 will have ``Content-Type: application/pgp-encrypted`` and is the content of the PGP encrypted submission. @@ -325,7 +322,7 @@ Requires authentication. .. code:: sh - DELETE /api/v1/sources/ + DELETE /api/v1/sources/ Response 200: @@ -342,7 +339,7 @@ Requires authentication. .. code:: sh - POST /api/v1/sources//star + POST /api/v1/sources//star Response 201 created: @@ -359,7 +356,7 @@ Requires authentication. .. code:: sh - DELETE /api/v1/sources//star + DELETE /api/v1/sources//star Response 200: @@ -376,7 +373,7 @@ Requires authentication. .. code:: sh - POST /api/v1/sources//flag + POST /api/v1/sources//flag Response 200: @@ -402,46 +399,46 @@ Response 200: .. code:: sh - { - "submissions": [ - { - "download_url": "/api/v1/sources/HUIQTCLJSN7PACRN4YTC4GUTGD2ZESBTTGAJ5LLFWL4UZY3RP4YE6NO2FL4NZLNFCAJE5TIJS7H3U5YTMC3Z3UNJNCB6PDHU5AMQBRA%3D/submissions/1/download/", - "filename": "1-inspirational_busman-msg.gpg", - "is_read": false, - "size": 604, - "source_url": "/api/v1/sources/HUIQTCLJSN7PACRN4YTC4GUTGD2ZESBTTGAJ5LLFWL4UZY3RP4YE6NO2FL4NZLNFCAJE5TIJS7H3U5YTMC3Z3UNJNCB6PDHU5AMQBRA%3D/", - "submission_id": 1, - "submission_url": "/api/v1/sources/HUIQTCLJSN7PACRN4YTC4GUTGD2ZESBTTGAJ5LLFWL4UZY3RP4YE6NO2FL4NZLNFCAJE5TIJS7H3U5YTMC3Z3UNJNCB6PDHU5AMQBRA%3D/submissions/1/" - }, - { - "download_url": "/api/v1/sources/HUIQTCLJSN7PACRN4YTC4GUTGD2ZESBTTGAJ5LLFWL4UZY3RP4YE6NO2FL4NZLNFCAJE5TIJS7H3U5YTMC3Z3UNJNCB6PDHU5AMQBRA%3D/submissions/2/download/", - "filename": "2-inspirational_busman-msg.gpg", - "is_read": false, - "size": 604, - "source_url": "/api/v1/sources/HUIQTCLJSN7PACRN4YTC4GUTGD2ZESBTTGAJ5LLFWL4UZY3RP4YE6NO2FL4NZLNFCAJE5TIJS7H3U5YTMC3Z3UNJNCB6PDHU5AMQBRA%3D/", - "submission_id": 2, - "submission_url": "/api/v1/sources/HUIQTCLJSN7PACRN4YTC4GUTGD2ZESBTTGAJ5LLFWL4UZY3RP4YE6NO2FL4NZLNFCAJE5TIJS7H3U5YTMC3Z3UNJNCB6PDHU5AMQBRA%3D/submissions/2/" - }, - { - "download_url": "/api/v1/sources/C7YGA52VCSAILDUGWQININHKV7MO3SPUV67HAZKDGKDEVMBZPNGAJSGN7JTG5CZ7WNA4VR36ZKQ7BPI4Z544WBBBOTLRTAYO7LAVPUA%3D/submissions/3/download/", - "filename": "1-masculine_internationalization-msg.gpg", - "is_read": false, - "size": 604, - "source_url": "/api/v1/sources/C7YGA52VCSAILDUGWQININHKV7MO3SPUV67HAZKDGKDEVMBZPNGAJSGN7JTG5CZ7WNA4VR36ZKQ7BPI4Z544WBBBOTLRTAYO7LAVPUA%3D/", - "submission_id": 3, - "submission_url": "/api/v1/sources/C7YGA52VCSAILDUGWQININHKV7MO3SPUV67HAZKDGKDEVMBZPNGAJSGN7JTG5CZ7WNA4VR36ZKQ7BPI4Z544WBBBOTLRTAYO7LAVPUA%3D/submissions/3/" - }, - { - "download_url": "/api/v1/sources/C7YGA52VCSAILDUGWQININHKV7MO3SPUV67HAZKDGKDEVMBZPNGAJSGN7JTG5CZ7WNA4VR36ZKQ7BPI4Z544WBBBOTLRTAYO7LAVPUA%3D/submissions/4/download/", - "filename": "2-masculine_internationalization-msg.gpg", - "is_read": false, - "size": 604, - "source_url": "/api/v1/sources/C7YGA52VCSAILDUGWQININHKV7MO3SPUV67HAZKDGKDEVMBZPNGAJSGN7JTG5CZ7WNA4VR36ZKQ7BPI4Z544WBBBOTLRTAYO7LAVPUA%3D/", - "submission_id": 4, - "submission_url": "/api/v1/sources/C7YGA52VCSAILDUGWQININHKV7MO3SPUV67HAZKDGKDEVMBZPNGAJSGN7JTG5CZ7WNA4VR36ZKQ7BPI4Z544WBBBOTLRTAYO7LAVPUA%3D/submissions/4/" - } - ] - } + { + "submissions": [ + { + "download_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/1/download", + "filename": "1-validated_benefactress-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a", + "submission_id": 1, + "submission_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/1" + }, + { + "download_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/2/download", + "filename": "2-validated_benefactress-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a", + "submission_id": 2, + "submission_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/2" + }, + { + "download_url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce/submissions/3/download", + "filename": "1-navigational_firearm-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce", + "submission_id": 3, + "submission_url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce/submissions/3" + }, + { + "download_url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce/submissions/4/download", + "filename": "2-navigational_firearm-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce", + "submission_id": 4, + "submission_url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce/submissions/4" + } + ] + } User ``[/user]`` ---------------- From ebe155364ef9a65231ad5d9dd1db359b73784b0f Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Mon, 9 Jul 2018 15:43:37 -0700 Subject: [PATCH 62/71] Database migration: Add migration and tests for source UUID column --- .../3d91d6948753_create_source_uuid_column.py | 69 +++++++++++ securedrop/models.py | 4 +- .../migrations/migration_3d91d6948753.py | 116 ++++++++++++++++++ 3 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 securedrop/alembic/versions/3d91d6948753_create_source_uuid_column.py create mode 100644 securedrop/tests/migrations/migration_3d91d6948753.py diff --git a/securedrop/alembic/versions/3d91d6948753_create_source_uuid_column.py b/securedrop/alembic/versions/3d91d6948753_create_source_uuid_column.py new file mode 100644 index 0000000000..4d9ebcbfe5 --- /dev/null +++ b/securedrop/alembic/versions/3d91d6948753_create_source_uuid_column.py @@ -0,0 +1,69 @@ +"""Create source UUID column + +Revision ID: 3d91d6948753 +Revises: faac8092c123 +Create Date: 2018-07-09 22:39:05.088008 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import quoted_name +import subprocess +import uuid + +# revision identifiers, used by Alembic. +revision = '3d91d6948753' +down_revision = 'faac8092c123' +branch_labels = None +depends_on = None + + +def upgrade(): + # Schema migration + op.rename_table('sources', 'sources_tmp') + + # Add UUID column. + op.add_column('sources_tmp', sa.Column('uuid', sa.String(length=36))) + + # Add UUIDs to sources_tmp table. + conn = op.get_bind() + sources = conn.execute(sa.text("SELECT * FROM sources_tmp")).fetchall() + + for source in sources: + id = source.id + source_uuid = str(uuid.uuid4()) + conn.execute( + sa.text("UPDATE sources_tmp SET uuid=('{}') WHERE id={}".format( + source_uuid, id))) + + # Now create new table with unique constraint applied. + op.create_table(quoted_name('sources', quote=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('uuid', sa.String(length=36), nullable=False), + sa.Column('filesystem_id', sa.String(length=96), nullable=True), + sa.Column('journalist_designation', sa.String(length=255), + nullable=False), + sa.Column('flagged', sa.Boolean(), nullable=True), + sa.Column('last_updated', sa.DateTime(), nullable=True), + sa.Column('pending', sa.Boolean(), nullable=True), + sa.Column('interaction_count', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('uuid'), + sa.UniqueConstraint('filesystem_id') + ) + + # Data Migration: move all sources into the new table. + conn.execute(''' + INSERT INTO sources + SELECT id, uuid, filesystem_id, journalist_designation, flagged, + last_updated, pending, interaction_count + FROM sources_tmp + ''') + + # Now delete the old table. + op.drop_table('sources_tmp') + + +def downgrade(): + with op.batch_alter_table('sources', schema=None) as batch_op: + batch_op.drop_column('uuid') diff --git a/securedrop/models.py b/securedrop/models.py index 5efac63075..1b190e7fa6 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -51,8 +51,7 @@ def get_one_or_else(query, logger, failure_method): class Source(db.Model): __tablename__ = 'sources' id = Column(Integer, primary_key=True) - uuid = Column(String(36), unique=True, nullable=False, - default=str(uuid.uuid4())) + uuid = Column(String(36), unique=True, nullable=False) filesystem_id = Column(String(96), unique=True) journalist_designation = Column(String(255), nullable=False) flagged = Column(Boolean, default=False) @@ -73,6 +72,7 @@ class Source(db.Model): def __init__(self, filesystem_id=None, journalist_designation=None): self.filesystem_id = filesystem_id self.journalist_designation = journalist_designation + self.uuid = str(uuid.uuid4()) def __repr__(self): return '' % (self.journalist_designation) diff --git a/securedrop/tests/migrations/migration_3d91d6948753.py b/securedrop/tests/migrations/migration_3d91d6948753.py new file mode 100644 index 0000000000..eab03a9eb3 --- /dev/null +++ b/securedrop/tests/migrations/migration_3d91d6948753.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- + +import random +import uuid + +from sqlalchemy import text +from sqlalchemy.exc import NoSuchColumnError + +from db import db +from journalist_app import create_app +from .helpers import random_bool, random_chars, random_datetime, bool_or_none + +random.seed('ᕕ( ᐛ )ᕗ') + + +class UpgradeTester(): + + '''This migration verifies that the UUID column now exists, and that + the data migration completed successfully. + ''' + + SOURCE_NUM = 200 + + def __init__(self, config): + self.config = config + self.app = create_app(config) + + def load_data(self): + with self.app.app_context(): + + for _ in range(self.SOURCE_NUM): + self.add_source() + + db.session.commit() + + @staticmethod + def add_source(): + filesystem_id = random_chars(96) if random_bool() else None + params = { + 'filesystem_id': filesystem_id, + 'journalist_designation': random_chars(50), + 'flagged': bool_or_none(), + 'last_updated': random_datetime(nullable=True), + 'pending': bool_or_none(), + 'interaction_count': random.randint(0, 1000), + } + sql = '''INSERT INTO sources (filesystem_id, journalist_designation, + flagged, last_updated, pending, interaction_count) + VALUES (:filesystem_id, :journalist_designation, :flagged, + :last_updated, :pending, :interaction_count) + ''' + db.engine.execute(text(sql), **params) + + def check_upgrade(self): + with self.app.app_context(): + sources = db.engine.execute( + text('SELECT * FROM sources')).fetchall() + assert len(sources) == self.SOURCE_NUM + + for source in sources: + assert source.uuid is not None + + +class DowngradeTester(): + + SOURCE_NUM = 200 + + def __init__(self, config): + self.config = config + self.app = create_app(config) + + def load_data(self): + with self.app.app_context(): + + for _ in range(self.SOURCE_NUM): + self.add_source() + + db.session.commit() + + @staticmethod + def add_source(): + filesystem_id = random_chars(96) if random_bool() else None + params = { + 'filesystem_id': filesystem_id, + 'uuid': str(uuid.uuid4()), + 'journalist_designation': random_chars(50), + 'flagged': bool_or_none(), + 'last_updated': random_datetime(nullable=True), + 'pending': bool_or_none(), + 'interaction_count': random.randint(0, 1000), + } + sql = '''INSERT INTO sources (filesystem_id, uuid, + journalist_designation, flagged, last_updated, pending, + interaction_count) + VALUES (:filesystem_id, :uuid, :journalist_designation, + :flagged, :last_updated, :pending, :interaction_count) + ''' + db.engine.execute(text(sql), **params) + + def check_downgrade(self): + '''Verify that the UUID column is now gone, but otherwise the table + has the expected number of rows. + ''' + with self.app.app_context(): + sql = "SELECT * FROM sources" + sources = db.engine.execute(text(sql)).fetchall() + + for source in sources: + try: + # This should produce an exception, as the column (should) + # be gone. + assert source['uuid'] is None + except NoSuchColumnError: + pass + + assert len(sources) == self.SOURCE_NUM From b5447d7b958f1d5fcda819b28b4c632754ec5480 Mon Sep 17 00:00:00 2001 From: heartsucker Date: Fri, 8 Jun 2018 17:55:00 +0200 Subject: [PATCH 63/71] updated schema check to handle whitespace differences (cherry picked from commit 1ce5455880dcec885b12f84d0e39e72f9a195a3c) --- securedrop/tests/test_alembic.py | 53 +++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/securedrop/tests/test_alembic.py b/securedrop/tests/test_alembic.py index 8fc9bda44f..3f58e0ee9a 100644 --- a/securedrop/tests/test_alembic.py +++ b/securedrop/tests/test_alembic.py @@ -42,12 +42,51 @@ def downgrade(alembic_config, migration): def get_schema(app): with app.app_context(): - return list(db.engine.execute(text(''' + result = list(db.engine.execute(text(''' SELECT type, name, tbl_name, sql FROM sqlite_master ORDER BY type, name, tbl_name '''))) + return {(x[0], x[1], x[2]): x[3] for x in result} + + +def assert_schemas_equal(left, right): + for (k, v) in left.items(): + if k not in right: + raise AssertionError( + 'Left contained {} but right did not'.format(k)) + if not ddl_equal(v, right[k]): + raise AssertionError( + 'Schema for {} did not match:\nLeft:\n{}\nRight:\n{}' + .format(k, v, right[k])) + right.pop(k) + + if right: + raise AssertionError( + 'Right had additional tables: {}'.format(right.keys())) + + +def ddl_equal(left, right): + '''Check the "tokenized" DDL is equivalent because, because sometimes + Alembic schemas append columns on the same line to the DDL comes out + like: + + column1 TEXT NOT NULL, column2 TEXT NOT NULL + + and SQLAlchemy comes out: + + column1 TEXT NOT NULL, + column2 TEXT NOT NULL + ''' + # ignore the autoindex cases + if left is None and right is None: + return True + + left = [x for x in left.split() if x] + right = [x for x in right.split() if x] + return left == right + def test_alembic_head_matches_db_models(journalist_app, alembic_config, @@ -71,10 +110,10 @@ def test_alembic_head_matches_db_models(journalist_app, # The initial migration creates the table 'alembic_version', but this is # not present in the schema created by `db.create_all()`. - alembic_schema = list(filter(lambda x: x[2] != 'alembic_version', - alembic_schema)) + alembic_schema = {k: v for k, v in alembic_schema.items() + if k[2] != 'alembic_version'} - assert alembic_schema == models_schema + assert_schemas_equal(alembic_schema, models_schema) @pytest.mark.parametrize('migration', ALL_MIGRATIONS) @@ -124,10 +163,10 @@ def test_schema_unchanged_after_up_then_downgrade(alembic_config, # The initial migration is a degenerate case because it creates the table # 'alembic_version', but rolling back the migration doesn't clear it. if len(migrations) == 1: - reverted_schema = list(filter(lambda x: x[2] != 'alembic_version', - reverted_schema)) + reverted_schema = {k: v for k, v in reverted_schema.items() + if k[2] != 'alembic_version'} - assert reverted_schema == original_schema + assert_schemas_equal(reverted_schema, original_schema) @pytest.mark.parametrize('migration', ALL_MIGRATIONS) From 25cec03424a0b12111c6f1d3ef47b1e70b39d487 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Wed, 11 Jul 2018 16:35:07 -0700 Subject: [PATCH 64/71] Alembic tests: Be more lenient with schema equality check During testing, I ran into an issue where there was a failure in the following case: reverted_schema was: CREATE TABLE "sources" ( id INTEGER NOT NULL, filesystem_id VARCHAR(96), journalist_designation VARCHAR(255) NOT NULL, flagged BOOLEAN, last_updated DATETIME, pending BOOLEAN, interaction_count INTEGER NOT NULL, PRIMARY KEY (id), CHECK (flagged IN (0, 1)), CHECK (pending IN (0, 1)), UNIQUE (filesystem_id) ) and original_schema was: CREATE TABLE sources ( id INTEGER NOT NULL, filesystem_id VARCHAR(96), journalist_designation VARCHAR(255) NOT NULL, flagged BOOLEAN, last_updated DATETIME, pending BOOLEAN, interaction_count INTEGER NOT NULL, PRIMARY KEY (id), UNIQUE (filesystem_id), CHECK (flagged IN (0, 1)), CHECK (pending IN (0, 1)) ) which fails for two reasons: * The unique constraint on filesystem_id is not at the same line in the CREATE TABLE statement. * The table name is quoted in one CREATE TABLE statement, but not in the other. In order to make our tests a little more lenient in this case (and not produce spurious test failures), we should: * Compare sorted lists consisting of the lines in each CREATE TABLE statement * Strip commas and double quotes for each element in the aforementioned lists --- securedrop/tests/test_alembic.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/securedrop/tests/test_alembic.py b/securedrop/tests/test_alembic.py index 3f58e0ee9a..56d783f05d 100644 --- a/securedrop/tests/test_alembic.py +++ b/securedrop/tests/test_alembic.py @@ -83,9 +83,14 @@ def ddl_equal(left, right): if left is None and right is None: return True - left = [x for x in left.split() if x] - right = [x for x in right.split() if x] - return left == right + left = [x for x in left.split('\n') if x] + right = [x for x in right.split('\n') if x] + + # Strip commas, whitespace, quotes + left = [x.replace("\"", "").replace(",", "").strip() for x in left] + right = [x.replace("\"", "").replace(",", "").strip() for x in right] + + return sorted(left) == sorted(right) def test_alembic_head_matches_db_models(journalist_app, From 900f3b3913acf93618f540b92e6337108304602b Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Wed, 11 Jul 2018 17:35:07 -0700 Subject: [PATCH 65/71] Journalist API: Also use UUID for submissions To prevent confusion, we also rename `uuid` to `source_uuid`. --- docs/development/journalist_api.rst | 156 +++++++++--------- ...f57ceef02_create_submission_uuid_column.py | 66 ++++++++ securedrop/journalist_app/api.py | 59 +++---- securedrop/models.py | 26 +-- .../migrations/migration_fccf57ceef02.py | 140 ++++++++++++++++ securedrop/tests/test_journalist_api.py | 94 ++++++----- 6 files changed, 380 insertions(+), 161 deletions(-) create mode 100644 securedrop/alembic/versions/fccf57ceef02_create_submission_uuid_column.py create mode 100644 securedrop/tests/migrations/migration_fccf57ceef02.py diff --git a/docs/development/journalist_api.rst b/docs/development/journalist_api.rst index 162d37296f..4ebbafb942 100644 --- a/docs/development/journalist_api.rst +++ b/docs/development/journalist_api.rst @@ -159,8 +159,8 @@ Response 200 (application/json): ] } -Individual Source ``[/sources/]`` ---------------------------------------- +Individual Source ``[/sources/]`` +---------------------------------------------- Requires authentication @@ -197,33 +197,33 @@ Requires authentication. .. code:: sh - GET /api/v1/sources//submissions + GET /api/v1/sources//submissions Response 200 (application/json): .. code:: sh { - "submissions": [ - { - "download_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/1/download", - "filename": "1-validated_benefactress-msg.gpg", - "is_read": false, - "size": 604, - "source_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a", - "submission_id": 1, - "submission_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/1" - }, - { - "download_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/2/download", - "filename": "2-validated_benefactress-msg.gpg", - "is_read": false, - "size": 604, - "source_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a", - "submission_id": 2, - "submission_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/2" - } - ] + "submissions": [ + { + "download_url": "/api/v1/sources/598b859c-72c7-4e53-a68c-b725eb514241/submissions/4c2e701c-70d2-4cb5-87c0-de59c2ebbc62/download", + "filename": "1-dejected_respondent-msg.gpg", + "is_read": false, + "size": 603, + "source_url": "/api/v1/sources/598b859c-72c7-4e53-a68c-b725eb514241", + "submission_url": "/api/v1/sources/598b859c-72c7-4e53-a68c-b725eb514241/submissions/4c2e701c-70d2-4cb5-87c0-de59c2ebbc62", + "uuid": "4c2e701c-70d2-4cb5-87c0-de59c2ebbc62" + }, + { + "download_url": "/api/v1/sources/598b859c-72c7-4e53-a68c-b725eb514241/submissions/c2e00865-8f75-444a-b5b4-88424024ce69/download", + "filename": "2-dejected_respondent-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/598b859c-72c7-4e53-a68c-b725eb514241", + "submission_url": "/api/v1/sources/598b859c-72c7-4e53-a68c-b725eb514241/submissions/c2e00865-8f75-444a-b5b4-88424024ce69", + "uuid": "c2e00865-8f75-444a-b5b4-88424024ce69" + } + ] } Get a single submission associated with a source [``GET``] @@ -233,20 +233,20 @@ Requires authentication. .. code:: sh - GET /api/v1/sources//submissions/ + GET /api/v1/sources//submissions/ Response 200 (application/json): .. code:: sh { - "download_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/1/download", - "filename": "1-validated_benefactress-msg.gpg", - "is_read": false, - "size": 604, - "source_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a", - "submission_id": 1, - "submission_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/1" + "download_url": "/api/v1/sources/598b859c-72c7-4e53-a68c-b725eb514241/submissions/4c2e701c-70d2-4cb5-87c0-de59c2ebbc62/download", + "filename": "1-dejected_respondent-msg.gpg", + "is_read": false, + "size": 603, + "source_url": "/api/v1/sources/598b859c-72c7-4e53-a68c-b725eb514241", + "submission_url": "/api/v1/sources/598b859c-72c7-4e53-a68c-b725eb514241/submissions/4c2e701c-70d2-4cb5-87c0-de59c2ebbc62", + "uuid": "4c2e701c-70d2-4cb5-87c0-de59c2ebbc62" } Add a reply to a source [``POST``] @@ -258,7 +258,7 @@ source. .. code:: sh - POST /api/v1/sources//reply + POST /api/v1/sources//reply with the reply in the request body: @@ -293,7 +293,7 @@ Requires authentication. .. code:: sh - DELETE /api/v1/sources//submissions/ + DELETE /api/v1/sources//submissions/ Response 200: @@ -310,7 +310,7 @@ Requires authentication. .. code:: sh - GET /api/v1/sources//submissions//download + GET /api/v1/sources//submissions//download Response 200 will have ``Content-Type: application/pgp-encrypted`` and is the content of the PGP encrypted submission. @@ -322,7 +322,7 @@ Requires authentication. .. code:: sh - DELETE /api/v1/sources/ + DELETE /api/v1/sources/ Response 200: @@ -339,7 +339,7 @@ Requires authentication. .. code:: sh - POST /api/v1/sources//star + POST /api/v1/sources//star Response 201 created: @@ -356,7 +356,7 @@ Requires authentication. .. code:: sh - DELETE /api/v1/sources//star + DELETE /api/v1/sources//star Response 200: @@ -373,7 +373,7 @@ Requires authentication. .. code:: sh - POST /api/v1/sources//flag + POST /api/v1/sources//flag Response 200: @@ -399,46 +399,46 @@ Response 200: .. code:: sh - { - "submissions": [ - { - "download_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/1/download", - "filename": "1-validated_benefactress-msg.gpg", - "is_read": false, - "size": 604, - "source_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a", - "submission_id": 1, - "submission_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/1" - }, - { - "download_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/2/download", - "filename": "2-validated_benefactress-msg.gpg", - "is_read": false, - "size": 604, - "source_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a", - "submission_id": 2, - "submission_url": "/api/v1/sources/9b6df7c9-a6b1-461d-91f0-5b715fc7a47a/submissions/2" - }, - { - "download_url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce/submissions/3/download", - "filename": "1-navigational_firearm-msg.gpg", - "is_read": false, - "size": 604, - "source_url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce", - "submission_id": 3, - "submission_url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce/submissions/3" - }, - { - "download_url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce/submissions/4/download", - "filename": "2-navigational_firearm-msg.gpg", - "is_read": false, - "size": 604, - "source_url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce", - "submission_id": 4, - "submission_url": "/api/v1/sources/f086bd03-1c89-49fb-82d5-00084c17b4ce/submissions/4" - } - ] - } + { + "submissions": [ + { + "download_url": "/api/v1/sources/1ed4c191-c6b1-463b-92a5-102deaf7d40a/submissions/e58f6206-fc12-4dbe-9a9c-84c3d82eea2f/download", + "filename": "1-abridged_psalmist-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/1ed4c191-c6b1-463b-92a5-102deaf7d40a", + "submission_url": "/api/v1/sources/1ed4c191-c6b1-463b-92a5-102deaf7d40a/submissions/e58f6206-fc12-4dbe-9a9c-84c3d82eea2f", + "uuid": "e58f6206-fc12-4dbe-9a9c-84c3d82eea2f" + }, + { + "download_url": "/api/v1/sources/1ed4c191-c6b1-463b-92a5-102deaf7d40a/submissions/a93d4123-a984-4740-9849-772c30694bab/download", + "filename": "2-abridged_psalmist-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/1ed4c191-c6b1-463b-92a5-102deaf7d40a", + "submission_url": "/api/v1/sources/1ed4c191-c6b1-463b-92a5-102deaf7d40a/submissions/a93d4123-a984-4740-9849-772c30694bab", + "uuid": "a93d4123-a984-4740-9849-772c30694bab" + }, + { + "download_url": "/api/v1/sources/598b859c-72c7-4e53-a68c-b725eb514241/submissions/4c2e701c-70d2-4cb5-87c0-de59c2ebbc62/download", + "filename": "1-dejected_respondent-msg.gpg", + "is_read": false, + "size": 603, + "source_url": "/api/v1/sources/598b859c-72c7-4e53-a68c-b725eb514241", + "submission_url": "/api/v1/sources/598b859c-72c7-4e53-a68c-b725eb514241/submissions/4c2e701c-70d2-4cb5-87c0-de59c2ebbc62", + "uuid": "4c2e701c-70d2-4cb5-87c0-de59c2ebbc62" + }, + { + "download_url": "/api/v1/sources/598b859c-72c7-4e53-a68c-b725eb514241/submissions/c2e00865-8f75-444a-b5b4-88424024ce69/download", + "filename": "2-dejected_respondent-msg.gpg", + "is_read": false, + "size": 604, + "source_url": "/api/v1/sources/598b859c-72c7-4e53-a68c-b725eb514241", + "submission_url": "/api/v1/sources/598b859c-72c7-4e53-a68c-b725eb514241/submissions/c2e00865-8f75-444a-b5b4-88424024ce69", + "uuid": "c2e00865-8f75-444a-b5b4-88424024ce69" + } + ] + } User ``[/user]`` ---------------- diff --git a/securedrop/alembic/versions/fccf57ceef02_create_submission_uuid_column.py b/securedrop/alembic/versions/fccf57ceef02_create_submission_uuid_column.py new file mode 100644 index 0000000000..4dead4bc5c --- /dev/null +++ b/securedrop/alembic/versions/fccf57ceef02_create_submission_uuid_column.py @@ -0,0 +1,66 @@ +"""create submission uuid column + +Revision ID: fccf57ceef02 +Revises: 3d91d6948753 +Create Date: 2018-07-12 00:06:20.891213 + +""" +from alembic import op +import sqlalchemy as sa + +import uuid + +# revision identifiers, used by Alembic. +revision = 'fccf57ceef02' +down_revision = '3d91d6948753' +branch_labels = None +depends_on = None + + +def upgrade(): + # Schema migration + op.rename_table('submissions', 'submissions_tmp') + + # Add UUID column. + op.add_column('submissions_tmp', sa.Column('uuid', sa.String(length=36))) + + # Add UUIDs to submissions_tmp table. + conn = op.get_bind() + submissions = conn.execute( + sa.text("SELECT * FROM submissions_tmp")).fetchall() + + for submission in submissions: + id = submission.id + submission_uuid = str(uuid.uuid4()) + conn.execute( + sa.text("""UPDATE submissions_tmp + SET uuid=('{}') + WHERE id={}""".format(submission_uuid, id))) + + # Now create new table with unique constraint applied. + op.create_table('submissions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('uuid', sa.String(length=36), nullable=False), + sa.Column('source_id', sa.Integer(), nullable=True), + sa.Column('filename', sa.String(length=255), nullable=False), + sa.Column('size', sa.Integer(), nullable=False), + sa.Column('downloaded', sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint(['source_id'], ['sources.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('uuid') + ) + + # Data Migration: move all submissions into the new table. + conn.execute(''' + INSERT INTO submissions + SELECT id, uuid, source_id, filename, size, downloaded + FROM submissions_tmp + ''') + + # Now delete the old table. + op.drop_table('submissions_tmp') + + +def downgrade(): + with op.batch_alter_table('submissions', schema=None) as batch_op: + batch_op.drop_column('uuid') diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index a40cbd91c1..99208d3c60 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -104,56 +104,57 @@ def get_all_sources(): return jsonify( {'sources': [source.to_json() for source in sources]}), 200 - @api.route('/sources/', methods=['GET', 'DELETE']) + @api.route('/sources/', methods=['GET', 'DELETE']) @token_required - def single_source(uuid): + def single_source(source_uuid): if request.method == 'GET': - source = get_or_404(Source, uuid, column=Source.uuid) + source = get_or_404(Source, source_uuid, column=Source.uuid) return jsonify(source.to_json()), 200 elif request.method == 'DELETE': - source = get_or_404(Source, uuid, column=Source.uuid) + source = get_or_404(Source, source_uuid, column=Source.uuid) utils.delete_collection(source.filesystem_id) return jsonify({'message': 'Source and submissions deleted'}), 200 - @api.route('/sources//add_star', methods=['POST']) + @api.route('/sources//add_star', methods=['POST']) @token_required - def add_star(uuid): - source = get_or_404(Source, uuid, column=Source.uuid) + def add_star(source_uuid): + source = get_or_404(Source, source_uuid, column=Source.uuid) utils.make_star_true(source.filesystem_id) db.session.commit() return jsonify({'message': 'Star added'}), 201 - @api.route('/sources//remove_star', methods=['DELETE']) + @api.route('/sources//remove_star', methods=['DELETE']) @token_required - def remove_star(uuid): - source = get_or_404(Source, uuid, column=Source.uuid) + def remove_star(source_uuid): + source = get_or_404(Source, source_uuid, column=Source.uuid) utils.make_star_false(source.filesystem_id) db.session.commit() return jsonify({'message': 'Star removed'}), 200 - @api.route('/sources//flag', methods=['POST']) + @api.route('/sources//flag', methods=['POST']) @token_required - def flag(uuid): - source = get_or_404(Source, uuid, + def flag(source_uuid): + source = get_or_404(Source, source_uuid, column=Source.uuid) source.flagged = True db.session.commit() return jsonify({'message': 'Source flagged for reply'}), 200 - @api.route('/sources//submissions', methods=['GET']) + @api.route('/sources//submissions', methods=['GET']) @token_required - def all_source_submissions(uuid): - source = get_or_404(Source, uuid, column=Source.uuid) + def all_source_submissions(source_uuid): + source = get_or_404(Source, source_uuid, column=Source.uuid) return jsonify( {'submissions': [submission.to_json() for submission in source.submissions]}), 200 - @api.route('/sources//submissions//download', # noqa + @api.route('/sources//submissions//download', # noqa methods=['GET']) @token_required - def download_submission(uuid, submission_id): - source = get_or_404(Source, uuid, column=Source.uuid) - submission = get_or_404(Submission, submission_id) + def download_submission(source_uuid, submission_uuid): + source = get_or_404(Source, source_uuid, column=Source.uuid) + submission = get_or_404(Submission, submission_uuid, + column=Submission.uuid) # Mark as downloaded submission.downloaded = True @@ -164,24 +165,26 @@ def download_submission(uuid, submission_id): mimetype="application/pgp-encrypted", as_attachment=True) - @api.route('/sources//submissions/', + @api.route('/sources//submissions/', methods=['GET', 'DELETE']) @token_required - def single_submission(uuid, submission_id): + def single_submission(source_uuid, submission_uuid): if request.method == 'GET': - submission = get_or_404(Submission, submission_id) + submission = get_or_404(Submission, submission_uuid, + column=Submission.uuid) return jsonify(submission.to_json()), 200 elif request.method == 'DELETE': - submission = get_or_404(Submission, submission_id) - source = get_or_404(Source, uuid, column=Source.uuid) + submission = get_or_404(Submission, submission_uuid, + column=Submission.uuid) + source = get_or_404(Source, source_uuid, column=Source.uuid) utils.delete_file(source.filesystem_id, submission.filename, submission) return jsonify({'message': 'Submission deleted'}), 200 - @api.route('/sources//reply', methods=['POST']) + @api.route('/sources//reply', methods=['POST']) @token_required - def post_reply(uuid): - source = get_or_404(Source, uuid, + def post_reply(source_uuid): + source = get_or_404(Source, source_uuid, column=Source.uuid) if request.json is None: abort(400, 'please send requests in valid JSON') diff --git a/securedrop/models.py b/securedrop/models.py index 1b190e7fa6..a6cc01fa29 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -115,7 +115,7 @@ def to_json(self): json_source = { 'uuid': self.uuid, - 'url': url_for('api.single_source', uuid=self.uuid), + 'url': url_for('api.single_source', source_uuid=self.uuid), 'journalist_designation': self.journalist_designation, 'is_flagged': self.flagged, 'is_starred': True if self.star else False, @@ -128,10 +128,11 @@ def to_json(self): 'number_of_documents': docs_msg_count['documents'], 'number_of_messages': docs_msg_count['messages'], 'submissions_url': url_for('api.all_source_submissions', - uuid=self.uuid), - 'add_star_url': url_for('api.add_star', uuid=self.uuid), - 'remove_star_url': url_for('api.remove_star', uuid=self.uuid), - 'reply_url': url_for('api.post_reply', uuid=self.uuid) + source_uuid=self.uuid), + 'add_star_url': url_for('api.add_star', source_uuid=self.uuid), + 'remove_star_url': url_for('api.remove_star', + source_uuid=self.uuid), + 'reply_url': url_for('api.post_reply', source_uuid=self.uuid) } return json_source @@ -139,6 +140,7 @@ def to_json(self): class Submission(db.Model): __tablename__ = 'submissions' id = Column(Integer, primary_key=True) + uuid = Column(String(36), unique=True, nullable=False) source_id = Column(Integer, ForeignKey('sources.id')) source = relationship( "Source", @@ -152,6 +154,7 @@ class Submission(db.Model): def __init__(self, source, filename): self.source_id = source.id self.filename = filename + self.uuid = str(uuid.uuid4()) self.size = os.stat(current_app.storage.path(source.filesystem_id, filename)).st_size @@ -160,17 +163,18 @@ def __repr__(self): def to_json(self): json_submission = { - 'source_url': url_for('api.single_source', uuid=self.source.uuid), + 'source_url': url_for('api.single_source', + source_uuid=self.source.uuid), 'submission_url': url_for('api.single_submission', - uuid=self.source.uuid, - submission_id=self.id), - 'submission_id': self.id, + source_uuid=self.source.uuid, + submission_uuid=self.uuid), 'filename': self.filename, 'size': self.size, 'is_read': self.downloaded, + 'uuid': self.uuid, 'download_url': url_for('api.download_submission', - uuid=self.source.uuid, - submission_id=self.id), + source_uuid=self.source.uuid, + submission_uuid=self.uuid), } return json_submission diff --git a/securedrop/tests/migrations/migration_fccf57ceef02.py b/securedrop/tests/migrations/migration_fccf57ceef02.py new file mode 100644 index 0000000000..d8e084c58c --- /dev/null +++ b/securedrop/tests/migrations/migration_fccf57ceef02.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- + +import random +import uuid + +from sqlalchemy import text +from sqlalchemy.exc import NoSuchColumnError + +from db import db +from journalist_app import create_app +from .helpers import random_bool, random_chars, random_datetime, bool_or_none + +random.seed('ᕕ( ᐛ )ᕗ') + + +def add_source(): + filesystem_id = random_chars(96) if random_bool() else None + params = { + 'filesystem_id': filesystem_id, + 'uuid': str(uuid.uuid4()), + 'journalist_designation': random_chars(50), + 'flagged': bool_or_none(), + 'last_updated': random_datetime(nullable=True), + 'pending': bool_or_none(), + 'interaction_count': random.randint(0, 1000), + } + sql = '''INSERT INTO sources (filesystem_id, uuid, + journalist_designation, flagged, last_updated, pending, + interaction_count) + VALUES (:filesystem_id, :uuid, :journalist_designation, + :flagged, :last_updated, :pending, :interaction_count) + ''' + db.engine.execute(text(sql), **params) + + +class UpgradeTester(): + + '''This migration verifies that the UUID column now exists, and that + the data migration completed successfully. + ''' + + SOURCE_NUM = 200 + + def __init__(self, config): + self.config = config + self.app = create_app(config) + + def load_data(self): + with self.app.app_context(): + + for _ in range(self.SOURCE_NUM): + add_source() + + for sid in range(1, self.SOURCE_NUM, 8): + for _ in range(random.randint(1, 3)): + self.add_submission(sid) + + # create "abandoned" submissions (issue #1189) + for sid in range(self.SOURCE_NUM, self.SOURCE_NUM + 50): + self.add_submission(sid) + + db.session.commit() + + @staticmethod + def add_submission(source_id): + params = { + 'source_id': source_id, + 'filename': random_chars(50), + 'size': random.randint(0, 1024 * 1024 * 500), + 'downloaded': bool_or_none(), + } + sql = '''INSERT INTO submissions (source_id, filename, size, + downloaded) + VALUES (:source_id, :filename, :size, :downloaded) + ''' + db.engine.execute(text(sql), **params) + + def check_upgrade(self): + with self.app.app_context(): + submissions = db.engine.execute( + text('SELECT * FROM submissions')).fetchall() + + for submission in submissions: + assert submission.uuid is not None + + +class DowngradeTester(): + + SOURCE_NUM = 200 + + def __init__(self, config): + self.config = config + self.app = create_app(config) + + def load_data(self): + with self.app.app_context(): + + for _ in range(self.SOURCE_NUM): + add_source() + + for sid in range(1, self.SOURCE_NUM, 8): + for _ in range(random.randint(1, 3)): + self.add_submission(sid) + + # create "abandoned" submissions (issue #1189) + for sid in range(self.SOURCE_NUM, self.SOURCE_NUM + 50): + self.add_submission(sid) + + db.session.commit() + + @staticmethod + def add_submission(source_id): + params = { + 'source_id': source_id, + 'uuid': str(uuid.uuid4()), + 'filename': random_chars(50), + 'size': random.randint(0, 1024 * 1024 * 500), + 'downloaded': bool_or_none(), + } + sql = '''INSERT INTO submissions (source_id, uuid, filename, size, + downloaded) + VALUES (:source_id, :uuid, :filename, :size, :downloaded) + ''' + db.engine.execute(text(sql), **params) + + def check_downgrade(self): + '''Verify that the UUID column is now gone, but otherwise the table + has the expected number of rows. + ''' + with self.app.app_context(): + sql = "SELECT * FROM submissions" + submissions = db.engine.execute(text(sql)).fetchall() + + for submission in submissions: + try: + # This should produce an exception, as the column (should) + # be gone. + assert submission['uuid'] is None + except NoSuchColumnError: + pass diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 721b3b077f..d8a8608a78 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -140,12 +140,12 @@ def test_user_without_token_cannot_get_protected_endpoints(journalist_app, uuid = test_source['source'].uuid protected_routes = [ url_for('api.get_all_sources'), - url_for('api.single_source', uuid=uuid), - url_for('api.all_source_submissions', uuid=uuid), - url_for('api.single_submission', uuid=uuid, - submission_id=test_source['submissions'][0].id), - url_for('api.download_submission', uuid=uuid, - submission_id=test_source['submissions'][0].id), + url_for('api.single_source', source_uuid=uuid), + url_for('api.all_source_submissions', source_uuid=uuid), + url_for('api.single_submission', source_uuid=uuid, + submission_uuid=test_source['submissions'][0].uuid), + url_for('api.download_submission', source_uuid=uuid, + submission_uuid=test_source['submissions'][0].uuid), url_for('api.get_all_submissions'), url_for('api.get_current_user') ] @@ -163,10 +163,10 @@ def test_user_without_token_cannot_delete_protected_endpoints(journalist_app, with journalist_app.app_context(): uuid = test_source['source'].uuid protected_routes = [ - url_for('api.single_source', uuid=uuid), - url_for('api.single_submission', uuid=uuid, - submission_id=test_source['submissions'][0].id), - url_for('api.remove_star', uuid=uuid), + url_for('api.single_source', source_uuid=uuid), + url_for('api.single_submission', source_uuid=uuid, + submission_uuid=test_source['submissions'][0].uuid), + url_for('api.remove_star', source_uuid=uuid), ] with journalist_app.test_client() as app: @@ -186,7 +186,7 @@ def test_attacker_cannot_create_valid_token_with_none_alg(journalist_app, algorithm_name='none') attacker_token = s.dumps({'id': test_journo['id']}).decode('ascii') - response = app.delete(url_for('api.single_source', uuid=uuid), + response = app.delete(url_for('api.single_source', source_uuid=uuid), headers=get_api_headers(attacker_token)) assert response.status_code == 403 @@ -209,7 +209,7 @@ def test_attacker_cannot_use_token_after_admin_deletes(journalist_app, db.session.commit() # Now this token should not be valid. - response = app.delete(url_for('api.single_source', uuid=uuid), + response = app.delete(url_for('api.single_source', source_uuid=uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 403 @@ -220,9 +220,9 @@ def test_user_without_token_cannot_post_protected_endpoints(journalist_app, with journalist_app.app_context(): uuid = test_source['source'].uuid protected_routes = [ - url_for('api.post_reply', uuid=uuid), - url_for('api.add_star', uuid=uuid), - url_for('api.flag', uuid=uuid) + url_for('api.post_reply', source_uuid=uuid), + url_for('api.add_star', source_uuid=uuid), + url_for('api.flag', source_uuid=uuid) ] with journalist_app.test_client() as app: @@ -246,7 +246,8 @@ def test_trailing_slash_cleanly_404s(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: uuid = test_source['source'].uuid - response = app.get(url_for('api.single_source', uuid=uuid) + '/', + response = app.get(url_for('api.single_source', + source_uuid=uuid) + '/', headers=get_api_headers(journalist_api_token)) json_response = json.loads(response.data) @@ -258,7 +259,7 @@ def test_authorized_user_gets_single_source(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: uuid = test_source['source'].uuid - response = app.get(url_for('api.single_source', uuid=uuid), + response = app.get(url_for('api.single_source', source_uuid=uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 @@ -270,7 +271,7 @@ def test_authorized_user_gets_single_source(journalist_app, test_source, def test_get_non_existant_source_404s(journalist_app, journalist_api_token): with journalist_app.test_client() as app: - response = app.get(url_for('api.single_source', uuid=1), + response = app.get(url_for('api.single_source', source_uuid=1), headers=get_api_headers(journalist_api_token)) assert response.status_code == 404 @@ -281,7 +282,7 @@ def test_authorized_user_can_flag_a_source(journalist_app, test_source, with journalist_app.test_client() as app: uuid = test_source['source'].uuid source_id = test_source['source'].id - response = app.post(url_for('api.flag', uuid=uuid), + response = app.post(url_for('api.flag', source_uuid=uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 @@ -295,7 +296,7 @@ def test_authorized_user_can_star_a_source(journalist_app, test_source, with journalist_app.test_client() as app: uuid = test_source['source'].uuid source_id = test_source['source'].id - response = app.post(url_for('api.add_star', uuid=uuid), + response = app.post(url_for('api.add_star', source_uuid=uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 201 @@ -310,11 +311,11 @@ def test_authorized_user_can_unstar_a_source(journalist_app, test_source, with journalist_app.test_client() as app: uuid = test_source['source'].uuid source_id = test_source['source'].id - response = app.post(url_for('api.add_star', uuid=uuid), + response = app.post(url_for('api.add_star', source_uuid=uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 201 - response = app.delete(url_for('api.remove_star', uuid=uuid), + response = app.delete(url_for('api.remove_star', source_uuid=uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 @@ -327,7 +328,7 @@ def test_disallowed_methods_produces_405(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: uuid = test_source['source'].uuid - response = app.delete(url_for('api.add_star', uuid=uuid), + response = app.delete(url_for('api.add_star', source_uuid=uuid), headers=get_api_headers(journalist_api_token)) json_response = json.loads(response.data) @@ -356,7 +357,8 @@ def test_authorized_user_get_source_submissions(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: uuid = test_source['source'].uuid - response = app.get(url_for('api.all_source_submissions', uuid=uuid), + response = app.get(url_for('api.all_source_submissions', + source_uuid=uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 @@ -374,17 +376,18 @@ def test_authorized_user_can_get_single_submission(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: - submission_id = test_source['source'].submissions[0].id + submission_uuid = test_source['source'].submissions[0].uuid uuid = test_source['source'].uuid - response = app.get(url_for('api.single_submission', uuid=uuid, - submission_id=submission_id), + response = app.get(url_for('api.single_submission', + source_uuid=uuid, + submission_uuid=submission_uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 json_response = json.loads(response.data) - assert json_response['submission_id'] == submission_id + assert json_response['uuid'] == submission_uuid assert json_response['is_read'] is False assert json_response['filename'] == \ test_source['source'].submissions[0].filename @@ -396,17 +399,18 @@ def test_authorized_user_can_delete_single_submission(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: - submission_id = test_source['source'].submissions[0].id + submission_uuid = test_source['source'].submissions[0].uuid uuid = test_source['source'].uuid - response = app.delete(url_for('api.single_submission', uuid=uuid, - submission_id=submission_id), + response = app.delete(url_for('api.single_submission', + source_uuid=uuid, + submission_uuid=submission_uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 # Submission now should be gone. assert Submission.query.filter( - Submission.id == submission_id).all() == [] + Submission.uuid == submission_uuid).all() == [] def test_authorized_user_can_delete_source_collection(journalist_app, @@ -414,7 +418,7 @@ def test_authorized_user_can_delete_source_collection(journalist_app, journalist_api_token): with journalist_app.test_client() as app: uuid = test_source['source'].uuid - response = app.delete(url_for('api.single_source', uuid=uuid), + response = app.delete(url_for('api.single_source', source_uuid=uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 @@ -427,17 +431,19 @@ def test_authorized_user_can_download_submission(journalist_app, test_source, journalist_api_token): with journalist_app.test_client() as app: - submission_id = test_source['source'].submissions[0].id + submission_uuid = test_source['source'].submissions[0].uuid uuid = test_source['source'].uuid - response = app.get(url_for('api.download_submission', uuid=uuid, - submission_id=submission_id), + response = app.get(url_for('api.download_submission', + source_uuid=uuid, + submission_uuid=submission_uuid), headers=get_api_headers(journalist_api_token)) assert response.status_code == 200 # Submission should now be marked as downloaded in the database - submission = Submission.query.get(submission_id) + submission = Submission.query.get( + test_source['source'].submissions[0].id) assert submission.downloaded # Response should be a PGP encrypted download @@ -484,7 +490,7 @@ def test_unencrypted_replies_get_rejected(journalist_app, journalist_api_token, with journalist_app.test_client() as app: uuid = test_source['source'].uuid reply_content = 'This is a plaintext reply' - response = app.post(url_for('api.post_reply', uuid=uuid), + response = app.post(url_for('api.post_reply', source_uuid=uuid), data=json.dumps({'reply': reply_content}), headers=get_api_headers(journalist_api_token)) assert response.status_code == 400 @@ -503,7 +509,7 @@ def test_authorized_user_can_add_reply(journalist_app, journalist_api_token, reply_content = current_app.crypto_util.gpg.encrypt( 'This is a plaintext reply', source_key).data - response = app.post(url_for('api.post_reply', uuid=uuid), + response = app.post(url_for('api.post_reply', source_uuid=uuid), data=json.dumps({'reply': reply_content}), headers=get_api_headers(journalist_api_token)) assert response.status_code == 201 @@ -533,7 +539,7 @@ def test_reply_without_content_400(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: uuid = test_source['source'].uuid - response = app.post(url_for('api.post_reply', uuid=uuid), + response = app.post(url_for('api.post_reply', source_uuid=uuid), data=json.dumps({'reply': ''}), headers=get_api_headers(journalist_api_token)) assert response.status_code == 400 @@ -543,7 +549,7 @@ def test_reply_without_reply_field_400(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: uuid = test_source['source'].uuid - response = app.post(url_for('api.post_reply', uuid=uuid), + response = app.post(url_for('api.post_reply', source_uuid=uuid), data=json.dumps({'other': 'stuff'}), headers=get_api_headers(journalist_api_token)) assert response.status_code == 400 @@ -553,7 +559,7 @@ def test_reply_without_json_400(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: uuid = test_source['source'].uuid - response = app.post(url_for('api.post_reply', uuid=uuid), + response = app.post(url_for('api.post_reply', source_uuid=uuid), data='invalid', headers=get_api_headers(journalist_api_token)) assert response.status_code == 400 @@ -563,7 +569,7 @@ def test_reply_with_valid_curly_json_400(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: uuid = test_source['source'].uuid - response = app.post(url_for('api.post_reply', uuid=uuid), + response = app.post(url_for('api.post_reply', source_uuid=uuid), data='{}', headers=get_api_headers(journalist_api_token)) assert response.status_code == 400 @@ -576,7 +582,7 @@ def test_reply_with_valid_square_json_400(journalist_app, journalist_api_token, test_source, test_journo): with journalist_app.test_client() as app: uuid = test_source['source'].uuid - response = app.post(url_for('api.post_reply', uuid=uuid), + response = app.post(url_for('api.post_reply', source_uuid=uuid), data='[]', headers=get_api_headers(journalist_api_token)) assert response.status_code == 400 From 7e7a44703f633d6ba8aa01e1dd97e9e306215705 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Thu, 12 Jul 2018 20:39:58 -0700 Subject: [PATCH 66/71] Journalist API: Add ETag header containing SHA256 hash of data Note that Flask's send_file does include ETags by default, but they are not hashes, so less useful for verifying downloads were not corrupted after fetching over Tor. The ETag in Flask is: ``` rv.set_etag('%s-%s-%s' % ( os.path.getmtime(filename), os.path.getsize(filename), adler32( filename.encode('utf-8') if isinstance(filename, text_type) else filename ) & 0xffffffff )) ``` https://github.com/pallets/flask/blob/161c43649d8c362c8359e0b79aeca40c754c5b51/flask/helpers.py#L616 --- docs/development/journalist_api.rst | 9 +++++++++ securedrop/journalist_app/api.py | 15 +++++++++++---- securedrop/tests/test_journalist_api.py | 5 +++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/development/journalist_api.rst b/docs/development/journalist_api.rst index 4ebbafb942..44b358bca6 100644 --- a/docs/development/journalist_api.rst +++ b/docs/development/journalist_api.rst @@ -315,6 +315,15 @@ Requires authentication. Response 200 will have ``Content-Type: application/pgp-encrypted`` and is the content of the PGP encrypted submission. +An ETag header is also present containing the SHA256 hash of the response data: + +.. code:: sh + + "sha256:c757c5aa263dc4a5a2bca8e7fe973367dbd2c1a6c780d19c0ba499e6b1b81efa" + +Note that these are not intended for cryptographic purposes and are present +for clients to check that downloads are not corrupted. + Delete a Source and all their associated submissions [``DELETE``] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 99208d3c60..4efeba22ef 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 +import hashlib import json from werkzeug.exceptions import default_exceptions # type: ignore @@ -160,10 +161,16 @@ def download_submission(source_uuid, submission_uuid): submission.downloaded = True db.session.commit() - return send_file(current_app.storage.path(source.filesystem_id, - submission.filename), - mimetype="application/pgp-encrypted", - as_attachment=True) + response = send_file(current_app.storage.path(source.filesystem_id, + submission.filename), + mimetype="application/pgp-encrypted", + as_attachment=True, + add_etags=False) # Disable Flask default ETag + + response.direct_passthrough = False + response.headers['Etag'] = '"sha256:{}"'.format( + hashlib.sha256(response.get_data()).hexdigest()) + return response @api.route('/sources//submissions/', methods=['GET', 'DELETE']) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index d8a8608a78..2ae3b990c6 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import hashlib import json import os @@ -449,6 +450,10 @@ def test_authorized_user_can_download_submission(journalist_app, # Response should be a PGP encrypted download assert response.mimetype == 'application/pgp-encrypted' + # Response should have Etag field with hash + assert response.headers['ETag'] == '"sha256:{}"'.format( + hashlib.sha256(response.data).hexdigest()) + def test_authorized_user_can_get_current_user_endpoint(journalist_app, test_source, From 5ab60f70449dc051ae4e8173f8a82574d2f8e122 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Thu, 12 Jul 2018 20:44:16 -0700 Subject: [PATCH 67/71] Remove unnecessary _insecure_api_views --- securedrop/journalist_app/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index 8493dbc285..9631e8fdc3 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -29,7 +29,6 @@ # http://flake8.pycqa.org/en/latest/user/error-codes.html?highlight=f401 from sdconfig import SDConfig # noqa: F401 -_insecure_api_views = ['api.get_endpoints', 'api.get_token'] _insecure_views = ['main.login', 'main.select_logo', 'static'] From dc21b3c080f1e7b39369749c42d5e90661afdc31 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Tue, 17 Jul 2018 15:49:17 -0700 Subject: [PATCH 68/71] Journalist API tests: Ensure root endpoint exposes all endpoints --- securedrop/tests/test_journalist_api.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 2ae3b990c6..7e77b26184 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -20,10 +20,9 @@ def test_unauthenticated_user_gets_all_endpoints(journalist_app): response = app.get(url_for('api.get_endpoints')) observed_endpoints = json.loads(response.data) - - for expected_endpoint in ['current_user_url', 'sources_url', - 'submissions_url']: - assert expected_endpoint in observed_endpoints.keys() + expected_endpoints = [u'current_user_url', u'submissions_url', + u'sources_url', u'auth_token_url'] + assert expected_endpoints == observed_endpoints.keys() def test_valid_user_can_get_an_api_token(journalist_app, test_journo): From 1b852b0e3e09550f6e3d7309789735577caafab4 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Tue, 17 Jul 2018 17:16:43 -0700 Subject: [PATCH 69/71] Indicate source.public_key setter and deleter are not implemented --- securedrop/models.py | 8 ++++++++ securedrop/tests/test_db.py | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/securedrop/models.py b/securedrop/models.py index a6cc01fa29..775d95dd08 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -110,6 +110,14 @@ def collection(self): def public_key(self): return current_app.crypto_util.export_pubkey(self.filesystem_id) + @public_key.setter + def public_key(self, value): + raise NotImplementedError + + @public_key.deleter + def public_key(self): + raise NotImplementedError + def to_json(self): docs_msg_count = self.documents_messages_count() diff --git a/securedrop/tests/test_db.py b/securedrop/tests/test_db.py index 1dd4a04299..8f8af2bcd3 100644 --- a/securedrop/tests/test_db.py +++ b/securedrop/tests/test_db.py @@ -4,10 +4,24 @@ from mock import MagicMock from utils import db_helper -from models import (Journalist, Submission, Reply, get_one_or_else, +from models import (Journalist, Submission, Reply, Source, get_one_or_else, LoginThrottledException) +def test_source_public_key_setter_unimplemented(journalist_app, test_source): + with journalist_app.app_context(): + source = Source.query.first() + with pytest.raises(NotImplementedError): + source.public_key = 'a curious developer tries to set a pubkey!' + + +def test_source_public_key_delete_unimplemented(journalist_app, test_source): + with journalist_app.app_context(): + source = Source.query.first() + with pytest.raises(NotImplementedError): + del source.public_key + + def test_get_one_or_else_returns_one(journalist_app, test_journo): with journalist_app.app_context(): # precondition: there must be one journalist From 57dd18183307434f67f88ba590800454c0f6e910 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Tue, 17 Jul 2018 17:28:11 -0700 Subject: [PATCH 70/71] Journalist API tests: Add submissions fixture I'm intentionally not using test_source in the test_submissions fixture as it is a bit messy/spaghetti for saving 1 LOC --- securedrop/tests/conftest.py | 10 +++++ securedrop/tests/test_journalist_api.py | 51 +++++++++++++------------ 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/securedrop/tests/conftest.py b/securedrop/tests/conftest.py index 238fe54231..eb207c9ff7 100644 --- a/securedrop/tests/conftest.py +++ b/securedrop/tests/conftest.py @@ -161,6 +161,16 @@ def test_admin(journalist_app): @pytest.fixture(scope='function') def test_source(journalist_app): + with journalist_app.app_context(): + source, codename = utils.db_helper.init_source() + return {'source': source, + 'codename': codename, + 'filesystem_id': source.filesystem_id, + 'uuid': source.uuid} + + +@pytest.fixture(scope='function') +def test_submissions(journalist_app): with journalist_app.app_context(): source, codename = utils.db_helper.init_source() utils.db_helper.submit(source, 2) diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index 7e77b26184..adc48423c4 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -135,17 +135,17 @@ def test_authorized_user_gets_all_sources(journalist_app, test_source, def test_user_without_token_cannot_get_protected_endpoints(journalist_app, - test_source): + test_submissions): with journalist_app.app_context(): - uuid = test_source['source'].uuid + uuid = test_submissions['source'].uuid protected_routes = [ url_for('api.get_all_sources'), url_for('api.single_source', source_uuid=uuid), url_for('api.all_source_submissions', source_uuid=uuid), url_for('api.single_submission', source_uuid=uuid, - submission_uuid=test_source['submissions'][0].uuid), + submission_uuid=test_submissions['submissions'][0].uuid), url_for('api.download_submission', source_uuid=uuid, - submission_uuid=test_source['submissions'][0].uuid), + submission_uuid=test_submissions['submissions'][0].uuid), url_for('api.get_all_submissions'), url_for('api.get_current_user') ] @@ -158,14 +158,14 @@ def test_user_without_token_cannot_get_protected_endpoints(journalist_app, assert response.status_code == 403 -def test_user_without_token_cannot_delete_protected_endpoints(journalist_app, - test_source): +def test_user_without_token_cannot_del_protected_endpoints(journalist_app, + test_submissions): with journalist_app.app_context(): - uuid = test_source['source'].uuid + uuid = test_submissions['source'].uuid protected_routes = [ url_for('api.single_source', source_uuid=uuid), url_for('api.single_submission', source_uuid=uuid, - submission_uuid=test_source['submissions'][0].uuid), + submission_uuid=test_submissions['submissions'][0].uuid), url_for('api.remove_star', source_uuid=uuid), ] @@ -336,7 +336,8 @@ def test_disallowed_methods_produces_405(journalist_app, test_source, assert json_response['error'] == 'Method Not Allowed' -def test_authorized_user_can_get_all_submissions(journalist_app, test_source, +def test_authorized_user_can_get_all_submissions(journalist_app, + test_submissions, journalist_api_token): with journalist_app.test_client() as app: response = app.get(url_for('api.get_all_submissions'), @@ -353,10 +354,11 @@ def test_authorized_user_can_get_all_submissions(journalist_app, test_source, assert observed_submissions == expected_submissions -def test_authorized_user_get_source_submissions(journalist_app, test_source, +def test_authorized_user_get_source_submissions(journalist_app, + test_submissions, journalist_api_token): with journalist_app.test_client() as app: - uuid = test_source['source'].uuid + uuid = test_submissions['source'].uuid response = app.get(url_for('api.all_source_submissions', source_uuid=uuid), headers=get_api_headers(journalist_api_token)) @@ -368,16 +370,16 @@ def test_authorized_user_get_source_submissions(journalist_app, test_source, submission in json_response['submissions']] expected_submissions = [submission.filename for submission in - test_source['source'].submissions] + test_submissions['source'].submissions] assert observed_submissions == expected_submissions def test_authorized_user_can_get_single_submission(journalist_app, - test_source, + test_submissions, journalist_api_token): with journalist_app.test_client() as app: - submission_uuid = test_source['source'].submissions[0].uuid - uuid = test_source['source'].uuid + submission_uuid = test_submissions['source'].submissions[0].uuid + uuid = test_submissions['source'].uuid response = app.get(url_for('api.single_submission', source_uuid=uuid, submission_uuid=submission_uuid), @@ -390,17 +392,17 @@ def test_authorized_user_can_get_single_submission(journalist_app, assert json_response['uuid'] == submission_uuid assert json_response['is_read'] is False assert json_response['filename'] == \ - test_source['source'].submissions[0].filename + test_submissions['source'].submissions[0].filename assert json_response['size'] == \ - test_source['source'].submissions[0].size + test_submissions['source'].submissions[0].size def test_authorized_user_can_delete_single_submission(journalist_app, - test_source, + test_submissions, journalist_api_token): with journalist_app.test_client() as app: - submission_uuid = test_source['source'].submissions[0].uuid - uuid = test_source['source'].uuid + submission_uuid = test_submissions['source'].submissions[0].uuid + uuid = test_submissions['source'].uuid response = app.delete(url_for('api.single_submission', source_uuid=uuid, submission_uuid=submission_uuid), @@ -428,11 +430,11 @@ def test_authorized_user_can_delete_source_collection(journalist_app, def test_authorized_user_can_download_submission(journalist_app, - test_source, + test_submissions, journalist_api_token): with journalist_app.test_client() as app: - submission_uuid = test_source['source'].submissions[0].uuid - uuid = test_source['source'].uuid + submission_uuid = test_submissions['source'].submissions[0].uuid + uuid = test_submissions['source'].uuid response = app.get(url_for('api.download_submission', source_uuid=uuid, @@ -443,7 +445,7 @@ def test_authorized_user_can_download_submission(journalist_app, # Submission should now be marked as downloaded in the database submission = Submission.query.get( - test_source['source'].submissions[0].id) + test_submissions['source'].submissions[0].id) assert submission.downloaded # Response should be a PGP encrypted download @@ -455,7 +457,6 @@ def test_authorized_user_can_download_submission(journalist_app, def test_authorized_user_can_get_current_user_endpoint(journalist_app, - test_source, test_journo, journalist_api_token): with journalist_app.test_client() as app: From e42c7f1844aaebfbf93904d8345ba5b6fae7a654 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Wed, 18 Jul 2018 16:26:37 -0700 Subject: [PATCH 71/71] Journalist API: Only show pending=False sources Sources should only be exposed to journalists when they have submitted something --- securedrop/journalist_app/api.py | 2 +- securedrop/tests/test_journalist_api.py | 4 ++-- securedrop/tests/utils/db_helper.py | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 4efeba22ef..7c5fd44ea4 100644 --- a/securedrop/journalist_app/api.py +++ b/securedrop/journalist_app/api.py @@ -101,7 +101,7 @@ def get_token(): @api.route('/sources', methods=['GET']) @token_required def get_all_sources(): - sources = Source.query.all() + sources = Source.query.filter_by(pending=False).all() return jsonify( {'sources': [source.to_json() for source in sources]}), 200 diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index adc48423c4..a882772912 100644 --- a/securedrop/tests/test_journalist_api.py +++ b/securedrop/tests/test_journalist_api.py @@ -119,7 +119,7 @@ def test_user_cannot_get_an_api_token_with_no_otp_field(journalist_app, assert observed_response['message'] == 'one_time_code field is missing' -def test_authorized_user_gets_all_sources(journalist_app, test_source, +def test_authorized_user_gets_all_sources(journalist_app, test_submissions, journalist_api_token): with journalist_app.test_client() as app: response = app.get(url_for('api.get_all_sources'), @@ -130,7 +130,7 @@ def test_authorized_user_gets_all_sources(journalist_app, test_source, assert response.status_code == 200 # We expect to see our test source in the response - assert test_source['source'].journalist_designation == \ + assert test_submissions['source'].journalist_designation == \ data['sources'][0]['journalist_designation'] diff --git a/securedrop/tests/utils/db_helper.py b/securedrop/tests/utils/db_helper.py index 70ef0b0682..3f4b68cf49 100644 --- a/securedrop/tests/utils/db_helper.py +++ b/securedrop/tests/utils/db_helper.py @@ -146,6 +146,7 @@ def submit(source, num_submissions): submissions = [] for _ in range(num_submissions): source.interaction_count += 1 + source.pending = False fpath = current_app.storage.save_message_submission( source.filesystem_id, source.interaction_count, @@ -154,6 +155,7 @@ def submit(source, num_submissions): ) submission = models.Submission(source, fpath) submissions.append(submission) + db.session.add(source) db.session.add(submission) db.session.commit()