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/main/__init__.py b/app/main/__init__.py index c1219ffbf..3bc30c684 100644 --- a/app/main/__init__.py +++ b/app/main/__init__.py @@ -1,7 +1,100 @@ -from flask import Blueprint +from flask import Blueprint, request, abort +from jwt import decode, DecodeError +import calendar +import time +import base64 +import hashlib +import hmac +AUTHORIZATION_HEADER = 'Authorization' +AUTHORIZATION_SCHEME = 'Bearer' +WINDOW = 1 main = Blueprint('main', __name__) +def get_secrets(service_identifier): + """ + Temp method until secrets are stored in database etc + :param service_identifier: + :return: (Boolean, String) + """ + secrets = { + 'service1': '1234' + } + if service_identifier not in secrets: + return False, None + return True, secrets[service_identifier] + + +def get_token_from_headers(headers): + auth_header = headers.get(AUTHORIZATION_HEADER, '') + if auth_header[:7] != AUTHORIZATION_SCHEME + " ": + return None + return auth_header[7:] + + +def token_is_valid(token): + try: + # decode token to get service identifier + # signature not checked + unverified = decode(token, verify=False, algorithms=['HS256']) + + # service identifier used to get secret + found, secret = get_secrets(unverified['iss']) + + # use secret to validate the token + verified = decode(token, key=secret.encode(), verify=True, algorithms=['HS256']) + + # check expiry + if not calendar.timegm(time.gmtime()) < verified['iat'] + WINDOW: + print("TIMESTAMP FAILED") + return False + + # check request + signed_url = base64.b64encode( + hmac.new( + secret.encode(), + "{} {}".format(request.method, request.path).encode(), + digestmod=hashlib.sha256 + ).digest() + ).decode() + if signed_url != verified['req']: + print("URL FAILED") + return False + + # check body + signed_json_request = base64.b64encode( + hmac.new( + secret.encode(), + request.data, + digestmod=hashlib.sha256 + ).digest() + ).decode() + + print(verified) + + if signed_json_request != verified['pay']: + print("PAYLOAD FAILED") + return False + + except DecodeError: + print("TOKEN VERIFICATION FAILED") + return False + + return True + + +def perform_authentication(): + incoming_token = get_token_from_headers(request.headers) + if not incoming_token: + abort(401) + if not token_is_valid(incoming_token): + abort(403) + + +main.before_request(perform_authentication) + + from .views import notifications, index +from . import errors diff --git a/app/main/errors.py b/app/main/errors.py new file mode 100644 index 000000000..9c7879b00 --- /dev/null +++ b/app/main/errors.py @@ -0,0 +1,36 @@ +from flask import jsonify + +from . 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..3031f04ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ Flask==0.10.1 -Flask-Script==2.0.5 \ No newline at end of file +Flask-Script==2.0.5 +PyJWT==1.4.0 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/test_authentication.py b/tests/app/main/test_authentication.py new file mode 100644 index 000000000..f8fd55f34 --- /dev/null +++ b/tests/app/main/test_authentication.py @@ -0,0 +1,82 @@ +from flask import json +import jwt +import hashlib +import hmac +import calendar +import time +import base64 + + +def sign_a_thing(thing_to_sign, secret_key): + return base64.b64encode( + hmac.new( + secret_key.encode(), + thing_to_sign.encode(), + digestmod=hashlib.sha256 + ).digest() + ) + + +def token(request_method, request_url, secret_key, service_id, json_body): + + # request method and resource path - hash of request url - POST /path-to-resource + signed_url = sign_a_thing("{} {}".format(request_method, request_url), secret_key) + signed_payload = sign_a_thing(json_body, secret_key) + + headers = { + "typ": "JWT", + "alg": "HS256" + } + + claims = { + 'iss': service_id, # issued by - identified by id of the service + 'iat': calendar.timegm(time.gmtime()), # issued at in epoch seconds + 'req': signed_url.decode(), + 'pay': signed_payload.decode() # signed payload + } + + return jwt.encode(payload=claims, key=secret_key, headers=headers).decode() + + +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_invalid_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'] == 'Forbidden, invalid authentication token provided' + + +def test_should_allow_request_with_valid_token(notify_api): + + body = { + "1": "A", + "3": "A", + "7": "A", + "15": "A", + "2": "B" + } + request_body = json.dumps(body) + + request_token = token("POST", "/", "1234", "service1", request_body) + + # from time import sleep + # sleep(4) + + response = notify_api.test_client().post( + "/", + data=request_body, + headers={ + 'Authorization': "Bearer {}".format(request_token) + }, + content_type='application/json' + ) + assert response.status_code == 200 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/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