From 0884eb49435c6688494c1692acc1e0002ebbc657 Mon Sep 17 00:00:00 2001 From: Dan Jones <dan.jones@noc.ac.uk> Date: Fri, 25 Nov 2022 16:11:07 +0000 Subject: [PATCH] refactor: client creds grant and bearer tokens Use client creds to issue a bearer token Authenticate subsequent endpoints with authorization header --- Pipfile | 1 + Pipfile.lock | 116 +++++++++++++++++++++++++++++++++++-- api.py | 8 ++- endpoints/auth_resource.py | 25 ++++++++ endpoints/notify.py | 24 ++++---- endpoints/receive.py | 27 ++++----- endpoints/send.py | 30 ++++------ endpoints/token.py | 43 ++++++++++++++ models/token.py | 92 +++++++++++++++++++++++++++++ 9 files changed, 311 insertions(+), 55 deletions(-) create mode 100644 endpoints/auth_resource.py create mode 100644 endpoints/token.py create mode 100644 models/token.py diff --git a/Pipfile b/Pipfile index 9510ffc..3956649 100644 --- a/Pipfile +++ b/Pipfile @@ -12,6 +12,7 @@ flask-restful = "*" marshmallow = "*" bson = "*" flask-cors = "*" +cryptography = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index a8acc9b..5e5da41 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "1147de24391cc36d2688e229ffc68c4a093efa85ef0a957f89206ba6784dc2b3" + "sha256": "d216458acf3005c6f459eb1d74f7d5bbda1a7b5e1e042efe855739943c4674d4" }, "pipfile-spec": 6, "requires": { @@ -38,6 +38,75 @@ "index": "pypi", "version": "==0.5.10" }, + "cffi": { + "hashes": [ + "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5", + "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef", + "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104", + "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426", + "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405", + "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375", + "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a", + "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e", + "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc", + "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf", + "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185", + "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497", + "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3", + "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35", + "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c", + "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83", + "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21", + "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca", + "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984", + "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac", + "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd", + "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee", + "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a", + "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2", + "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192", + "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7", + "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585", + "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f", + "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e", + "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27", + "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b", + "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e", + "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e", + "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d", + "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c", + "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415", + "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82", + "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02", + "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314", + "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325", + "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c", + "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3", + "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914", + "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045", + "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d", + "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9", + "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5", + "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2", + "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c", + "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3", + "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2", + "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8", + "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d", + "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d", + "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", + "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162", + "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76", + "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4", + "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e", + "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9", + "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6", + "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b", + "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01", + "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0" + ], + "version": "==1.15.1" + }, "click": { "hashes": [ "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", @@ -46,6 +115,38 @@ "markers": "python_version >= '3.7'", "version": "==8.1.3" }, + "cryptography": { + "hashes": [ + "sha256:068147f32fa662c81aebab95c74679b401b12b57494872886eb5c1139250ec5d", + "sha256:06fc3cc7b6f6cca87bd56ec80a580c88f1da5306f505876a71c8cfa7050257dd", + "sha256:25c1d1f19729fb09d42e06b4bf9895212292cb27bb50229f5aa64d039ab29146", + "sha256:402852a0aea73833d982cabb6d0c3bb582c15483d29fb7085ef2c42bfa7e38d7", + "sha256:4e269dcd9b102c5a3d72be3c45d8ce20377b8076a43cbed6f660a1afe365e436", + "sha256:5419a127426084933076132d317911e3c6eb77568a1ce23c3ac1e12d111e61e0", + "sha256:554bec92ee7d1e9d10ded2f7e92a5d70c1f74ba9524947c0ba0c850c7b011828", + "sha256:5e89468fbd2fcd733b5899333bc54d0d06c80e04cd23d8c6f3e0542358c6060b", + "sha256:65535bc550b70bd6271984d9863a37741352b4aad6fb1b3344a54e6950249b55", + "sha256:6ab9516b85bebe7aa83f309bacc5f44a61eeb90d0b4ec125d2d003ce41932d36", + "sha256:6addc3b6d593cd980989261dc1cce38263c76954d758c3c94de51f1e010c9a50", + "sha256:728f2694fa743a996d7784a6194da430f197d5c58e2f4e278612b359f455e4a2", + "sha256:785e4056b5a8b28f05a533fab69febf5004458e20dad7e2e13a3120d8ecec75a", + "sha256:78cf5eefac2b52c10398a42765bfa981ce2372cbc0457e6bf9658f41ec3c41d8", + "sha256:7f836217000342d448e1c9a342e9163149e45d5b5eca76a30e84503a5a96cab0", + "sha256:8d41a46251bf0634e21fac50ffd643216ccecfaf3701a063257fe0b2be1b6548", + "sha256:984fe150f350a3c91e84de405fe49e688aa6092b3525f407a18b9646f6612320", + "sha256:9b24bcff7853ed18a63cfb0c2b008936a9554af24af2fb146e16d8e1aed75748", + "sha256:b1b35d9d3a65542ed2e9d90115dfd16bbc027b3f07ee3304fc83580f26e43249", + "sha256:b1b52c9e5f8aa2b802d48bd693190341fae201ea51c7a167d69fc48b60e8a959", + "sha256:bbf203f1a814007ce24bd4d51362991d5cb90ba0c177a9c08825f2cc304d871f", + "sha256:be243c7e2bfcf6cc4cb350c0d5cdf15ca6383bbcb2a8ef51d3c9411a9d4386f0", + "sha256:bfbe6ee19615b07a98b1d2287d6a6073f734735b49ee45b11324d85efc4d5cbd", + "sha256:c46837ea467ed1efea562bbeb543994c2d1f6e800785bd5a2c98bc096f5cb220", + "sha256:dfb4f4dd568de1b6af9f4cda334adf7d72cf5bc052516e1b2608b683375dd95c", + "sha256:ed7b00096790213e09eb11c97cc6e2b757f15f3d2f85833cd2d3ec3fe37c1722" + ], + "index": "pypi", + "version": "==38.0.3" + }, "flask": { "hashes": [ "sha256:642c450d19c4ad482f96729bd2a8f6d32554aa1e231f4f6b4e7e5264b16cca2b", @@ -87,11 +188,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:da31db32b304314d044d3c12c79bd59e307889b287ad12ff387b3500835fc2ab", - "sha256:ddb0e35065e8938f867ed4928d0ae5bf2a53b7773871bfe6bcc7e4fcdc7dea43" + "sha256:d5059f9f1e8e41f80e9c56c2ee58811450c31984dfa625329ffd7c0dad88a73b", + "sha256:d84d17e21670ec07990e1044a99efe8d615d860fd176fc29ef5c306068fda313" ], "markers": "python_version < '3.10'", - "version": "==5.0.0" + "version": "==5.1.0" }, "itsdangerous": { "hashes": [ @@ -195,6 +296,13 @@ "index": "pypi", "version": "==2.3.0" }, + "pycparser": { + "hashes": [ + "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", + "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" + ], + "version": "==2.21" + }, "pyparsing": { "hashes": [ "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", diff --git a/api.py b/api.py index fad511c..0645556 100644 --- a/api.py +++ b/api.py @@ -1,22 +1,26 @@ from flask import Flask from flask_restful import Api -from endpoints.hello import HelloWorld from endpoints.clients import Client, ClientList from endpoints.receive import Receive from endpoints.send import Send from endpoints.notify import Notify +from endpoints.token import Token from flask_cors import CORS +from models.token import TokenModel + +token = TokenModel() +token.setSecret() app = Flask(__name__) api = Api(app) CORS(app, resources={r"*": {"origins": "http://localhost:8086"}}) -api.add_resource(HelloWorld, "/") api.add_resource(ClientList, "/client") api.add_resource(Client, "/client/<client_id>") api.add_resource(Receive, "/receive") api.add_resource(Send, "/send") api.add_resource(Notify, "/notify") +api.add_resource(Token, "/token") if __name__ == "__main__": app.run(debug=True, port=8087) diff --git a/endpoints/auth_resource.py b/endpoints/auth_resource.py new file mode 100644 index 0000000..5ebd201 --- /dev/null +++ b/endpoints/auth_resource.py @@ -0,0 +1,25 @@ +import json +from flask_restful import Resource, abort +from models.token import TokenModel + +class AuthResource(Resource): + + def __init__(self): + self.token = TokenModel() + with open("clients.json", "r") as clients_file: + self.clients = json.load(clients_file) + + def auth(self, request): + allow = False + auth = request.headers.get('Authorization', False) + if auth: + token = auth.split(' ').pop() + parsed = self.token.validate(token) + if parsed['valid']: + client = self.clients.get(parsed['client_id']) + if client: + self.client = client + allow = True + if not allow: + abort(403, message="Invalid token") + return allow \ No newline at end of file diff --git a/endpoints/notify.py b/endpoints/notify.py index ae750ab..a953db8 100644 --- a/endpoints/notify.py +++ b/endpoints/notify.py @@ -1,22 +1,21 @@ -from flask_restful import Resource, request, abort +import json +from flask_restful import request, abort from marshmallow import Schema, fields import pika -import json +from endpoints.auth_resource import AuthResource + class NotifySchema(Schema): - client_id = fields.Str(required=True) - secret = fields.Str(required=True) body = fields.Str(required=True) -class Notify(Resource): +class Notify(AuthResource): clients = None schema = None def __init__(self): + super().__init__() self.schema = NotifySchema() - with open("clients.json", "r") as clients_file: - self.clients = json.load(clients_file) - + def post(self): args = request.get_json() errors = self.schema.validate(args) @@ -29,14 +28,11 @@ class Notify(Resource): 'topic': 'broadcast', 'message': body, } - client_id = args.get("client_id") - notify_queue = client_id + "-broadcast" - if client_id in self.clients: - client = self.clients.get(client_id) - if args.get("secret") == client.get("secret"): - allow = True + allow = self.auth(request) + if allow: + notify_queue = self.client['client_id'] + "-broadcast" connection = pika.BlockingConnection(pika.ConnectionParameters(host="localhost")) channel = connection.channel() channel.queue_declare(queue=notify_queue, durable=True) diff --git a/endpoints/receive.py b/endpoints/receive.py index 86d2487..bc1feba 100644 --- a/endpoints/receive.py +++ b/endpoints/receive.py @@ -1,23 +1,22 @@ -from flask_restful import Resource, request, abort +from flask_restful import request, abort from marshmallow import Schema, fields import pika import json +from models.token import TokenModel +from endpoints.auth_resource import AuthResource class ReceiveQuerySchema(Schema): - client_id = fields.Str(required=True) - secret = fields.Str(required=True) max_messages = fields.Int(required=False) -class Receive(Resource): +class Receive(AuthResource): clients = None schema = None def __init__(self): + super().__init__() self.schema = ReceiveQuerySchema() - with open("clients.json", "r") as clients_file: - self.clients = json.load(clients_file) def get(self): errors = self.schema.validate(request.args) @@ -25,15 +24,12 @@ class Receive(Resource): abort(400, message=str(errors)) messages = [] - allow = False max_messages = request.args.get("max_messages", 10) - client_id = request.args.get("client_id") - inbox_queue = client_id + "-inbox" - if client_id in self.clients: - client = self.clients.get(client_id) - if request.args.get("secret") == client.get("secret"): - allow = True - + + allow = self.auth(request) + if allow: + inbox_queue = self.client['client_id'] + "-inbox" + if allow: connection = pika.BlockingConnection( pika.ConnectionParameters(host="localhost") @@ -51,7 +47,4 @@ class Receive(Resource): break channel.close() connection.close() - - else: - abort(403, message="Invalid client credentials") return messages diff --git a/endpoints/send.py b/endpoints/send.py index 17a958e..900e8a5 100644 --- a/endpoints/send.py +++ b/endpoints/send.py @@ -1,40 +1,34 @@ -from flask_restful import Resource, request, abort +import json +from flask_restful import request, abort from marshmallow import Schema, fields import pika -import json +from endpoints.auth_resource import AuthResource class SendSchema(Schema): - client_id = fields.Str(required=True) - secret = fields.Str(required=True) body = fields.Str(required=True) topic = fields.Str(required=True) -class Send(Resource): +class Send(AuthResource): clients = None schema = None def __init__(self): + super().__init__() self.schema = SendSchema() - with open("clients.json", "r") as clients_file: - self.clients = json.load(clients_file) - + def post(self): args = request.get_json() errors = self.schema.validate(args) if errors: abort(400, message=str(errors)) - allow = False - body = args.get("body") - topic = args.get("topic") - client_id = args.get("client_id") - outbox_queue = client_id + "-outbox" - if client_id in self.clients: - client = self.clients.get(client_id) - if args.get("secret") == client.get("secret"): - allow = True - + allow = self.auth(request) + if allow: + body = args.get("body") + topic = args.get("topic") + outbox_queue = self.client['client_id'] + "-outbox" + connection = pika.BlockingConnection(pika.ConnectionParameters(host="localhost")) channel = connection.channel() channel.queue_declare(queue=outbox_queue, durable=True) diff --git a/endpoints/token.py b/endpoints/token.py new file mode 100644 index 0000000..01dbedf --- /dev/null +++ b/endpoints/token.py @@ -0,0 +1,43 @@ +import json +from flask_restful import Resource, request, abort +from marshmallow import Schema, fields +import pika +from models.token import TokenModel + + +class TokenQuerySchema(Schema): + client_id = fields.Str(required=True) + secret = fields.Str(required=True) + + +class Token(Resource): + clients = None + schema = None + model = None + + def __init__(self): + self.schema = TokenQuerySchema() + self.model = TokenModel() + with open("clients.json", "r") as clients_file: + self.clients = json.load(clients_file) + + def get(self): + errors = self.schema.validate(request.args) + if errors: + abort(400, message=str(errors)) + + token = None + allow = False + max_messages = request.args.get("max_messages", 10) + client_id = request.args.get("client_id") + if client_id in self.clients: + client = self.clients.get(client_id) + if request.args.get("secret") == client.get("secret"): + allow = True + + if allow: + token = self.model.get(client_id) + + else: + abort(403, message="Invalid client credentials") + return token \ No newline at end of file diff --git a/models/token.py b/models/token.py new file mode 100644 index 0000000..21efd85 --- /dev/null +++ b/models/token.py @@ -0,0 +1,92 @@ +from cryptography.fernet import Fernet,InvalidToken +import datetime +import os +import json + + +TOKENS = {} + + +class TokenModel(): + clients = None + schema = None + key = None + fernet = None + token_lifetime_hours = None + env_lifetime = 'SOAR_TOKEN_LIFETIME' + env_secret = 'SOAR_TOKEN_SECRET' + + def __init__(self): + self.getFernet() + self.token_lifetime_hours = os.getenv(self.env_lifetime, 24) + + def getFernet(self): + self.fernet = Fernet(self.getKey().encode()) + + def getKey(self): + key = os.getenv(self.env_secret) + print(key) + if not key: + key = Fernet.generate_key().decode() + os.environ[self.env_secret] = key + self.key = key + return self.key + + def setSecret(self): + if not os.getenv(self.env_secret): + os.environ[self.env_secret] = self.getKey() + + def getExpiry(self): + now = datetime.datetime.utcnow() + expires = now + datetime.timedelta(hours=self.token_lifetime_hours) + return expires.isoformat() + + def encrypt(self, client_id): + try: + expiry = self.getExpiry() + token_content = { + 'client_id': client_id, + 'expiry': expiry + } + token = self.fernet.encrypt(json.dumps(token_content).encode()).decode() + print(f"Encode: {token}") + return { + 'token': token, + 'expiry': expiry + } + except KeyError as e: + return None + + def decrypt(self, token): + try: + print(f"Decode: {token}") + content = json.loads(self.fernet.decrypt(token.encode()).decode()) + return content + except (InvalidToken,KeyError) as e: + return None + + def get(self, client_id): + response = self.encrypt(client_id) + TOKENS[response['token']] = client_id + return response + + def validate(self, token): + response = { + 'valid': False + } + if token in TOKENS: + content = self.decrypt(token) + if content: + now = datetime.datetime.utcnow() + expires = datetime.datetime.fromisoformat(content['expiry']) + response['valid'] = expires > now + if response['valid']: + response.update(content) + else: + del TOKENS[token] + else: + del TOKENS[token] + return response + + + -- GitLab