diff --git a/README.md b/README.md index 639fa11f2..95fda4be3 100644 --- a/README.md +++ b/README.md @@ -7,4 +7,4 @@ Application for the notification api. Read and write notifications/status queue. Get and update notification status. -mkvirtualenv -p /usr/local/bin/python3 notify-api \ No newline at end of file +mkvirtualenv -p /usr/local/bin/python3 notifications-api \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index e2acdc531..1c12d0066 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,9 +1,12 @@ import os from flask._compat import string_types -from flask import Flask +from flask import Flask, _request_ctx_stack +from werkzeug.local import LocalProxy from config import configs +api_user = LocalProxy(lambda: _request_ctx_stack.top.api_user) + def create_app(config_name): application = Flask(__name__) diff --git a/app/main/__init__.py b/app/main/__init__.py index c1219ffbf..9631e7011 100644 --- a/app/main/__init__.py +++ b/app/main/__init__.py @@ -1,7 +1,15 @@ from flask import Blueprint +from app.main.authentication.auth import requires_auth +AUTHORIZATION_HEADER = 'Authorization' +AUTHORIZATION_SCHEME = 'Bearer' +WINDOW = 1 main = Blueprint('main', __name__) -from .views import notifications, index +main.before_request(requires_auth) + + +from app.main.views import notifications, index +from app.main import errors diff --git a/app/main/authentication/auth.py b/app/main/authentication/auth.py new file mode 100644 index 000000000..2762d5052 --- /dev/null +++ b/app/main/authentication/auth.py @@ -0,0 +1,51 @@ +from flask import request, jsonify, _request_ctx_stack +from client.authentication import decode_jwt_token, get_token_issuer +from client.errors import TokenDecodeError, TokenRequestError, TokenExpiredError, TokenPayloadError + + +def authentication_response(message, code): + return jsonify( + error=message + ), code + + +def requires_auth(): + auth_header = request.headers.get('Authorization', None) + if not auth_header: + return authentication_response('Unauthorized, authentication token must be provided', 401) + + auth_scheme = auth_header[:7] + + if auth_scheme != 'Bearer ': + return authentication_response('Unauthorized, authentication bearer scheme must be used', 401) + + try: + auth_token = auth_header[7:] + api_client = fetch_client(get_token_issuer(auth_token)) + + if api_client is None: + authentication_response("Invalid credentials", 403) + + decode_jwt_token( + auth_token, + api_client['secret'], + request.method, + request.path, + request.data.decode() if request.data else None + ) + _request_ctx_stack.top.api_user = api_client + except TokenRequestError: + return authentication_response("Invalid token: request", 403) + except TokenExpiredError: + return authentication_response("Invalid token: expired", 403) + except TokenPayloadError: + return authentication_response("Invalid token: payload", 403) + except TokenDecodeError: + return authentication_response("Invalid token: signature", 403) + + +def fetch_client(client): + return { + "client": client, + "secret": "secret" + } diff --git a/app/main/errors.py b/app/main/errors.py new file mode 100644 index 000000000..d7720e5ac --- /dev/null +++ b/app/main/errors.py @@ -0,0 +1,36 @@ +from flask import jsonify + +from app.main import main + + +@main.app_errorhandler(400) +def bad_request(e): + return jsonify(error=str(e.description)), 400 + + +@main.app_errorhandler(401) +def unauthorized(e): + error_message = "Unauthorized, authentication token must be provided" + return jsonify(error=error_message), 401, [('WWW-Authenticate', 'Bearer')] + + +@main.app_errorhandler(403) +def forbidden(e): + error_message = "Forbidden, invalid authentication token provided" + return jsonify(error=error_message), 403 + + +@main.app_errorhandler(404) +def page_not_found(e): + return jsonify(error=e.description or "Not found"), 404 + + +@main.app_errorhandler(429) +def limit_exceeded(e): + return jsonify(error=str(e.description)), 429 + + +@main.app_errorhandler(500) +def internal_server_error(e): + # TODO: log the error + return jsonify(error="Internal error"), 500 diff --git a/app/main/views/index.py b/app/main/views/index.py index ad8c1c98d..4c1b535e5 100644 --- a/app/main/views/index.py +++ b/app/main/views/index.py @@ -3,5 +3,10 @@ from .. import main @main.route('/', methods=['GET']) -def index(): +def get_index(): + return jsonify(result="hello world"), 200 + + +@main.route('/', methods=['POST']) +def post_index(): return jsonify(result="hello world"), 200 diff --git a/requirements.txt b/requirements.txt index f49991eb8..34cea9bc1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ Flask==0.10.1 -Flask-Script==2.0.5 \ No newline at end of file +Flask-Script==2.0.5 +PyJWT==1.4.0 + +git+https://github.com/alphagov/notifications-python-client.git@0.1.5#egg=notifications-python-client==0.1.5 diff --git a/requirements_for_test.txt b/requirements_for_test.txt index f8aeacc70..263c2f4da 100644 --- a/requirements_for_test.txt +++ b/requirements_for_test.txt @@ -1,3 +1,6 @@ -r requirements.txt pep8==1.5.7 -pytest==2.8.1 \ No newline at end of file +pytest==2.8.1 +pytest-mock==0.8.1 +pytest-cov==2.2.0 +mock==1.0.1 \ No newline at end of file diff --git a/tests/app/__init__.py b/tests/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/app/main/views/__init__.py b/tests/app/main/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/app/main/views/test_authentication.py b/tests/app/main/views/test_authentication.py new file mode 100644 index 000000000..dd7d482cd --- /dev/null +++ b/tests/app/main/views/test_authentication.py @@ -0,0 +1,131 @@ +from flask import json +from client.authentication import create_jwt_token + + +def test_should_not_allow_request_with_no_token(notify_api): + response = notify_api.test_client().get("/") + assert response.status_code == 401 + data = json.loads(response.get_data()) + assert data['error'] == 'Unauthorized, authentication token must be provided' + + +def test_should_not_allow_request_with_incorrect_header(notify_api): + response = notify_api.test_client().get( + "/", + headers={ + 'Authorization': 'Basic 1234' + } + ) + assert response.status_code == 401 + data = json.loads(response.get_data()) + assert data['error'] == 'Unauthorized, authentication bearer scheme must be used' + + +def test_should_not_allow_request_with_incorrect_token(notify_api): + response = notify_api.test_client().get( + "/", + headers={ + 'Authorization': 'Bearer 1234' + } + ) + assert response.status_code == 403 + data = json.loads(response.get_data()) + assert data['error'] == 'Invalid token: signature' + + +def test_should_not_allow_incorrect_path(notify_api): + token = create_jwt_token(request_method="GET", request_path="/bad", secret="secret", client_id="client_id") + response = notify_api.test_client().get( + "/", + headers={ + 'Authorization': "Bearer {}".format(token) + } + ) + assert response.status_code == 403 + data = json.loads(response.get_data()) + assert data['error'] == 'Invalid token: request' + + +def test_should_not_allow_incorrect_method(notify_api): + token = create_jwt_token(request_method="POST", request_path="/", secret="secret", client_id="client_id") + response = notify_api.test_client().get( + "/", + headers={ + 'Authorization': "Bearer {}".format(token) + } + ) + assert response.status_code == 403 + data = json.loads(response.get_data()) + assert data['error'] == 'Invalid token: request' + + +def test_should_not_allow_invalid_secret(notify_api): + token = create_jwt_token(request_method="POST", request_path="/", secret="not-so-secret", client_id="client_id") + response = notify_api.test_client().get( + "/", + headers={ + 'Authorization': "Bearer {}".format(token) + } + ) + assert response.status_code == 403 + data = json.loads(response.get_data()) + assert data['error'] == 'Invalid token: signature' + + +def test_should_allow_valid_token(notify_api): + token = create_jwt_token(request_method="GET", request_path="/", secret="secret", client_id="client_id") + response = notify_api.test_client().get( + "/", + headers={ + 'Authorization': 'Bearer {}'.format(token) + } + ) + assert response.status_code == 200 + + +def test_should_allow_valid_token_with_post_body(notify_api): + json_body = json.dumps({ + "key1": "value1", + "key2": "value2", + "key3": "value3" + }) + token = create_jwt_token( + request_method="POST", + request_path="/", + secret="secret", + client_id="client_id", + request_body=json_body + ) + response = notify_api.test_client().post( + "/", + data=json_body, + headers={ + 'Authorization': 'Bearer {}'.format(token) + } + ) + assert response.status_code == 200 + + +def test_should_not_allow_valid_token_with_invalid_post_body(notify_api): + json_body = json.dumps({ + "key1": "value1", + "key2": "value2", + "key3": "value3" + }) + token = create_jwt_token( + request_method="POST", + request_path="/", + secret="secret", + client_id="client_id", + request_body=json_body + ) + response = notify_api.test_client().post( + "/", + data="spurious", + headers={ + 'Authorization': 'Bearer {}'.format(token) + } + ) + assert response.status_code == 403 + data = json.loads(response.get_data()) + assert data['error'] == 'Invalid token: payload' diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..7d8cf6f23 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +import pytest +import mock +from app import create_app +from config import configs + + +@pytest.fixture(scope='session') +def notify_api(request): + app = create_app('test') + ctx = app.app_context() + ctx.push() + + def teardown(): + ctx.pop() + + request.addfinalizer(teardown) + return app + + +@pytest.fixture(scope='function') +def notify_config(notify_api): + notify_api.config['NOTIFY_API_ENVIRONMENT'] = 'test' + notify_api.config.from_object(configs['test']) + + +@pytest.fixture(scope='function') +def os_environ(request): + env_patch = mock.patch('os.environ', {}) + request.addfinalizer(env_patch.stop) + + return env_patch.start() diff --git a/tests/test_app.py b/tests/test_app.py deleted file mode 100644 index 3fbfeedd1..000000000 --- a/tests/test_app.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_app(): - assert 1 == 1