From dbf70ec1ebf2458cfc93ed0cdec3f3cbca288e07 Mon Sep 17 00:00:00 2001 From: Martyn Inglis Date: Tue, 15 Dec 2015 10:47:20 +0000 Subject: [PATCH] First pass at implementing API authentication using new JWT tokens - NOTE - this does not manage secrets. There is only one URL and there is no functionality implemented - prior to rolling out we need to store secrets properly Uses the JWT libraries in [https://github.com/alphagov/notifications-python-client](https://github.com/alphagov/notifications-python-client) - Tokens are checked on every request and will be rejected if token is invalid as per the rules in the python clients. --- app/__init__.py | 5 +- app/main/__init__.py | 91 +------------- app/main/authentication/auth.py | 51 ++++++++ requirements.txt | 2 +- tests/app/main/test_authentication.py | 82 ------------ tests/app/main/views/test_authentication.py | 131 ++++++++++++++++++++ 6 files changed, 190 insertions(+), 172 deletions(-) create mode 100644 app/main/authentication/auth.py delete mode 100644 tests/app/main/test_authentication.py create mode 100644 tests/app/main/views/test_authentication.py 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 3bc30c684..67cf4dcfb 100644 --- a/app/main/__init__.py +++ b/app/main/__init__.py @@ -1,10 +1,5 @@ -from flask import Blueprint, request, abort -from jwt import decode, DecodeError -import calendar -import time -import base64 -import hashlib -import hmac +from flask import Blueprint +from app.main.authentication.auth import requires_auth AUTHORIZATION_HEADER = 'Authorization' AUTHORIZATION_SCHEME = 'Bearer' @@ -13,87 +8,7 @@ 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) +main.before_request(requires_auth) from .views import notifications, index diff --git a/app/main/authentication/auth.py b/app/main/authentication/auth.py new file mode 100644 index 000000000..e0dcd0966 --- /dev/null +++ b/app/main/authentication/auth.py @@ -0,0 +1,51 @@ +from flask import request, jsonify, _request_ctx_stack +from client.jwt 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/requirements.txt b/requirements.txt index 27f67358a..3fd5aeed9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ Flask==0.10.1 Flask-Script==2.0.5 PyJWT==1.4.0 -git+https://github.com/alphagov/notifications-python-client.git@0.0.2#egg=notifications-python-client==0.0.2 +git+https://github.com/alphagov/notifications-python-client.git@0.1.2#egg=notifications-python-client==0.1.2 diff --git a/tests/app/main/test_authentication.py b/tests/app/main/test_authentication.py deleted file mode 100644 index f8fd55f34..000000000 --- a/tests/app/main/test_authentication.py +++ /dev/null @@ -1,82 +0,0 @@ -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/test_authentication.py b/tests/app/main/views/test_authentication.py new file mode 100644 index 000000000..f823e83e5 --- /dev/null +++ b/tests/app/main/views/test_authentication.py @@ -0,0 +1,131 @@ +from flask import json +from client.jwt 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 == 201 + + +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'