Implement a JWT header into base client

- adds a base client
- adds a notifications client

These do not proxy onto genuine methods. This pull request is the basic implication of the API Client. Still needs a few things before is ready, notably proper logging and actual API endpoints to hook into.

Basic premise is to deliver the JWT tokens required for Notify API authentication so we can discuss the implementation/premise.
This commit is contained in:
Martyn Inglis
2015-12-11 16:57:00 +00:00
parent e25ca0e434
commit 6deaa61011
11 changed files with 256 additions and 7 deletions

View File

@@ -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
mkvirtualenv -p /usr/local/bin/python3 notifications-api

View File

@@ -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

36
app/main/errors.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -1,2 +1,3 @@
Flask==0.10.1
Flask-Script==2.0.5
Flask-Script==2.0.5
PyJWT==1.4.0

View File

@@ -1,3 +1,6 @@
-r requirements.txt
pep8==1.5.7
pytest==2.8.1
pytest==2.8.1
pytest-mock==0.8.1
pytest-cov==2.2.0
mock==1.0.1

0
tests/app/__init__.py Normal file
View File

View File

@@ -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

View File

31
tests/conftest.py Normal file
View File

@@ -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()

View File

@@ -1,2 +0,0 @@
def test_app():
assert 1 == 1