Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add type annotations to journalist_app.api #5464

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@ disallow_untyped_defs = True
[mypy-journalist_app.models]
disallow_untyped_defs = True

[mypy-journalist_app.api]
disallow_untyped_defs = True

[mypy-config]
ignore_errors = True
128 changes: 69 additions & 59 deletions securedrop/journalist_app/api.py
Original file line number Diff line number Diff line change
@@ -1,69 +1,69 @@
import json

from datetime import datetime, timedelta
from typing import Tuple, Callable, Any

import flask
import werkzeug
from flask import abort, Blueprint, current_app, jsonify, request
from functools import wraps

from sqlalchemy.exc import IntegrityError
from os import path
from uuid import UUID
from werkzeug.exceptions import default_exceptions # type: ignore
from werkzeug.exceptions import default_exceptions

from db import db
from journalist_app import utils
from models import (Journalist, Reply, Source, Submission,
LoginThrottledException, InvalidUsernameException,
BadTokenException, WrongPasswordException)
from sdconfig import SDConfig
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
"""
auth_token = request.headers.get('Authorization').split(" ")[1]
user = Journalist.validate_api_token_and_get_user(auth_token)
return user
def _authenticate_user_from_auth_header(request: flask.Request) -> Journalist:
Copy link
Contributor Author

@nabla-c0d3 nabla-c0d3 Aug 22, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was code to parse the Authorization header in both get_user_object() and token_required(), so i combined them into just one function (because get_user_object() was causing a lot typing errors).

I would suggest passing the resulting/authenticated user as an argument to functions decorated with @token_require so that the views that need to know who the current user is don't need to parse the Auth header again (as done in all_source_replies() for example).
I can make the change if that's useful.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would generally be better to make functional changes in separate PRs, particularly when they're optimizations and involve authentication, instead of mixing the refactoring with the type hinting.

I preferred the old name, get_user_object: it was accurate, if less precise, and a lot shorter.

try:
auth_header = request.headers['Authorization']
except KeyError:
return abort(403, 'API token not found in Authorization header.')

if auth_header:
split = auth_header.split(" ")
if len(split) != 2 or split[0] != 'Token':
abort(403, 'Malformed authorization header.')
auth_token = split[1]
else:
auth_token = ''
authenticated_user = Journalist.validate_api_token_and_get_user(auth_token)
if not authenticated_user:
return abort(403, 'API token is invalid or expired.')
return authenticated_user


def token_required(f):
def token_required(f: Callable) -> Callable:
@wraps(f)
def decorated_function(*args, **kwargs):
try:
auth_header = request.headers['Authorization']
except KeyError:
return abort(403, 'API token not found in Authorization header.')

if auth_header:
split = auth_header.split(" ")
if len(split) != 2 or split[0] != 'Token':
abort(403, 'Malformed authorization header.')
auth_token = split[1]
else:
auth_token = ''
if not Journalist.validate_api_token_and_get_user(auth_token):
return abort(403, 'API token is invalid or expired.')
def decorated_function(*args: Any, **kwargs: Any) -> Any:
_authenticate_user_from_auth_header(request)
return f(*args, **kwargs)
return decorated_function


def get_or_404(model, object_id, column=''):
if column:
result = model.query.filter(column == object_id).one_or_none()
else:
result = model.query.get(object_id)
def get_or_404(model: db.Model, object_id: str, column: str) -> db.Model:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we always specify column as parameter here, so lgtm

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed.

result = model.query.filter(column == object_id).one_or_none()
if result is None:
abort(404)
return result


def make_blueprint(config):
def make_blueprint(config: SDConfig) -> Blueprint:
api = Blueprint('api', __name__)

@api.route('/')
def get_endpoints():
def get_endpoints() -> Tuple[flask.Response, int]:
endpoints = {'sources_url': '/api/v1/sources',
'current_user_url': '/api/v1/user',
'submissions_url': '/api/v1/submissions',
Expand All @@ -73,7 +73,7 @@ def get_endpoints():

# Before every post, we validate the payload before processing the request
@api.before_request
def validate_data():
def validate_data() -> None:
if request.method == 'POST':
# flag, star, and logout can have empty payloads
if not request.data:
Expand All @@ -86,28 +86,28 @@ def validate_data():
for endpoint in dataless_endpoints:
if request.endpoint == 'api.' + endpoint:
return
return abort(400, 'malformed request')
abort(400, 'malformed request')
# other requests must have valid JSON payload
else:
try:
json.loads(request.data.decode('utf-8'))
except (ValueError):
return abort(400, 'malformed request')
abort(400, 'malformed request')

@api.route('/token', methods=['POST'])
def get_token():
def get_token() -> Tuple[flask.Response, int]:
creds = json.loads(request.data.decode('utf-8'))

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')
abort(400, 'username field is missing')
if passphrase is None:
return abort(400, 'passphrase field is missing')
abort(400, 'passphrase field is missing')
if one_time_code is None:
return abort(400, 'one_time_code field is missing')
abort(400, 'one_time_code field is missing')

try:
journalist = Journalist.login(username, passphrase, one_time_code)
Expand All @@ -134,41 +134,43 @@ def get_token():

@api.route('/sources', methods=['GET'])
@token_required
def get_all_sources():
def get_all_sources() -> Tuple[flask.Response, int]:
sources = Source.query.filter_by(pending=False, deleted_at=None).all()
return jsonify(
{'sources': [source.to_json() for source in sources]}), 200

@api.route('/sources/<source_uuid>', methods=['GET', 'DELETE'])
@token_required
def single_source(source_uuid):
def single_source(source_uuid: str) -> Tuple[flask.Response, int]:
if request.method == 'GET':
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, source_uuid, column=Source.uuid)
utils.delete_collection(source.filesystem_id)
return jsonify({'message': 'Source and submissions deleted'}), 200
else:
abort(404)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good addition, as Flask's implicit support for HEAD means we're setting up a 500 error when we fall out of the if without handling it, but I think a 405 (method not allowed) status would be more accurate here and the other places you've caught this.


@api.route('/sources/<source_uuid>/add_star', methods=['POST'])
@token_required
def add_star(source_uuid):
def add_star(source_uuid: str) -> Tuple[flask.Response, int]:
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/<source_uuid>/remove_star', methods=['DELETE'])
@token_required
def remove_star(source_uuid):
def remove_star(source_uuid: str) -> Tuple[flask.Response, int]:
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/<source_uuid>/flag', methods=['POST'])
@token_required
def flag(source_uuid):
def flag(source_uuid: str) -> Tuple[flask.Response, int]:
source = get_or_404(Source, source_uuid,
column=Source.uuid)
source.flagged = True
Expand All @@ -177,7 +179,7 @@ def flag(source_uuid):

@api.route('/sources/<source_uuid>/submissions', methods=['GET'])
@token_required
def all_source_submissions(source_uuid):
def all_source_submissions(source_uuid: str) -> Tuple[flask.Response, int]:
source = get_or_404(Source, source_uuid, column=Source.uuid)
return jsonify(
{'submissions': [submission.to_json() for
Expand All @@ -186,7 +188,7 @@ def all_source_submissions(source_uuid):
@api.route('/sources/<source_uuid>/submissions/<submission_uuid>/download', # noqa
methods=['GET'])
@token_required
def download_submission(source_uuid, submission_uuid):
def download_submission(source_uuid: str, submission_uuid: str) -> flask.Response:
get_or_404(Source, source_uuid, column=Source.uuid)
submission = get_or_404(Submission, submission_uuid,
column=Submission.uuid)
Expand All @@ -200,7 +202,7 @@ def download_submission(source_uuid, submission_uuid):
@api.route('/sources/<source_uuid>/replies/<reply_uuid>/download',
methods=['GET'])
@token_required
def download_reply(source_uuid, reply_uuid):
def download_reply(source_uuid: str, reply_uuid: str) -> flask.Response:
get_or_404(Source, source_uuid, column=Source.uuid)
reply = get_or_404(Reply, reply_uuid, column=Reply.uuid)

Expand All @@ -209,7 +211,7 @@ def download_reply(source_uuid, reply_uuid):
@api.route('/sources/<source_uuid>/submissions/<submission_uuid>',
methods=['GET', 'DELETE'])
@token_required
def single_submission(source_uuid, submission_uuid):
def single_submission(source_uuid: str, submission_uuid: str) -> Tuple[flask.Response, int]:
if request.method == 'GET':
get_or_404(Source, source_uuid, column=Source.uuid)
submission = get_or_404(Submission, submission_uuid, column=Submission.uuid)
Expand All @@ -219,10 +221,12 @@ def single_submission(source_uuid, submission_uuid):
submission = get_or_404(Submission, submission_uuid, column=Submission.uuid)
utils.delete_file_object(submission)
return jsonify({'message': 'Submission deleted'}), 200
else:
abort(404)

@api.route('/sources/<source_uuid>/replies', methods=['GET', 'POST'])
@token_required
def all_source_replies(source_uuid):
def all_source_replies(source_uuid: str) -> Tuple[flask.Response, int]:
if request.method == 'GET':
source = get_or_404(Source, source_uuid, column=Source.uuid)
return jsonify(
Expand All @@ -237,7 +241,7 @@ def all_source_replies(source_uuid):
if 'reply' not in request.json:
abort(400, 'reply not found in request body')

user = get_user_object(request)
user = _authenticate_user_from_auth_header(request)

data = request.json
if not data['reply']:
Expand Down Expand Up @@ -282,54 +286,60 @@ def all_source_replies(source_uuid):
return jsonify({'message': 'Your reply has been stored',
'uuid': reply.uuid,
'filename': reply.filename}), 201
else:
abort(404)

@api.route('/sources/<source_uuid>/replies/<reply_uuid>',
methods=['GET', 'DELETE'])
@token_required
def single_reply(source_uuid, reply_uuid):
def single_reply(source_uuid: str, reply_uuid: str) -> Tuple[flask.Response, int]:
get_or_404(Source, source_uuid, column=Source.uuid)
reply = get_or_404(Reply, reply_uuid, column=Reply.uuid)
if request.method == 'GET':
return jsonify(reply.to_json()), 200
elif request.method == 'DELETE':
utils.delete_file_object(reply)
return jsonify({'message': 'Reply deleted'}), 200
else:
abort(404)

@api.route('/submissions', methods=['GET'])
@token_required
def get_all_submissions():
def get_all_submissions() -> Tuple[flask.Response, int]:
submissions = Submission.query.all()
return jsonify({'submissions': [submission.to_json() for
submission in submissions if submission.source]}), 200

@api.route('/replies', methods=['GET'])
@token_required
def get_all_replies():
def get_all_replies() -> Tuple[flask.Response, int]:
replies = Reply.query.all()
return jsonify(
{'replies': [reply.to_json() for reply in replies if reply.source]}), 200

@api.route('/user', methods=['GET'])
@token_required
def get_current_user():
user = get_user_object(request)
def get_current_user() -> Tuple[flask.Response, int]:
user = _authenticate_user_from_auth_header(request)
return jsonify(user.to_json()), 200

@api.route('/logout', methods=['POST'])
@token_required
def logout():
user = get_user_object(request)
auth_token = request.headers.get('Authorization').split(" ")[1]
def logout() -> Tuple[flask.Response, int]:
user = _authenticate_user_from_auth_header(request)
auth_token = request.headers['Authorization'].split(" ")[1]
utils.revoke_token(user, auth_token)
return jsonify({'message': 'Your token has been revoked.'}), 200

def _handle_api_http_exception(error):
def _handle_api_http_exception(
error: werkzeug.exceptions.HTTPException
) -> Tuple[flask.Response, int]:
# 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
return response, error.code # type: ignore

for code in default_exceptions:
api.errorhandler(code)(_handle_api_http_exception)
Expand Down
2 changes: 1 addition & 1 deletion securedrop/journalist_app/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ def col_download_all(cols_selected: List[str]) -> werkzeug.Response:
return download("all", submissions)


def serve_file_with_etag(db_obj: Union[Reply, Submission]) -> werkzeug.Response:
def serve_file_with_etag(db_obj: Union[Reply, Submission]) -> flask.Response:
file_path = current_app.storage.path(db_obj.source.filesystem_id, db_obj.filename)
response = send_file(file_path,
mimetype="application/pgp-encrypted",
Expand Down
2 changes: 1 addition & 1 deletion securedrop/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,7 @@ def validate_token_is_not_expired_or_invalid(token):
return True

@staticmethod
def validate_api_token_and_get_user(token: str) -> 'Union[Journalist, None]':
def validate_api_token_and_get_user(token: str) -> 'Optional[Journalist]':
s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'])
try:
data = s.loads(token)
Expand Down