summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <lwirzenius@wikimedia.org>2019-06-30 19:27:56 +0300
committerLars Wirzenius <lwirzenius@wikimedia.org>2019-06-30 19:27:56 +0300
commita906ff6c3f437c98438fad7ba0abfea2d4eec85e (patch)
treedc976325376381315b40df33ab727b093b0a5557
parentc639e5b36d688953393d7e801204193e11515cde (diff)
downloadwmf-ci-arch-a906ff6c3f437c98438fad7ba0abfea2d4eec85e.tar.gz
Add: sketch for implementing HTTP APIs in Python simply
-rw-r--r--api.py138
-rwxr-xr-xcreate-token44
-rwxr-xr-xgenerate-rsa-key34
3 files changed, 216 insertions, 0 deletions
diff --git a/api.py b/api.py
new file mode 100644
index 0000000..0eb850f
--- /dev/null
+++ b/api.py
@@ -0,0 +1,138 @@
+import logging
+import sys
+
+import Crypto.PublicKey.RSA
+import bottle
+import jwt
+
+
+class TokenParser:
+
+ def __init__(self, pubkey):
+ self._pubkey = pubkey
+
+ def parse_token(self, token_text):
+ return jwt.decode(
+ token_text,
+ key=self._pubkey.exportKey('OpenSSH'),
+ audience=None,
+ options={'verify_aud': False})
+
+
+class AccessChecker:
+
+ def __init__(self, pubkey):
+ self._parser = TokenParser(pubkey)
+
+ def access_is_allowed(self, headers, required_scopes):
+ token = self._get_token(headers)
+ logging.debug('Access token %r', token)
+ if token is None and len(required_scopes) != 0:
+ logging.error('No valid access token')
+ return False
+
+ scopes = token.get('scope', '').split()
+ missing = set(required_scopes).difference(scopes)
+ if missing:
+ logging.error(
+ 'Required scopes that are missing from token: %r', missing)
+ return False
+
+ return True
+
+ def _get_token(self, headers):
+ token_text = self._get_token_text(headers)
+ if token_text is None:
+ return None
+ return self._parser.parse_token(token_text)
+
+ def _get_token_text(self, headers):
+ v = headers.get('Authorization', '')
+ words = v.split()
+ if len(words) == 2:
+ keyword, token_text = words
+ if keyword.lower() == 'bearer':
+ return token_text
+
+
+class API:
+
+ def __init__(self, app, token_pubkey):
+ self._checker = AccessChecker(token_pubkey)
+ self._add_routes(app, self.get_routes())
+
+ def get_routes(self):
+ raise NotImplementedError()
+
+ def _add_routes(self, app, routes):
+ for route in routes:
+ func = route.pop('func')
+ scopes = route.pop('scopes')
+ callback = lambda **kwargs: self.check(func, scopes, kwargs)
+ route = dict(route)
+ route['callback'] = callback
+ app.route(**route)
+
+ def check(self, func, required_scopes, kwargs):
+ r = bottle.request
+ logging.debug('Checking access for request %s %s', r.method, r.path)
+
+ if self._checker.access_is_allowed(bottle.request.headers, required_scopes):
+ logging.info('Serving request %s %s', r.method, r.path)
+ ret = func(**kwargs)
+ logging.info('Result: %r', ret)
+ return ret
+
+ logging.error('Request denied %s %s', r.method, r.path)
+ return bottle.HTTPError(400)
+
+
+class Controller(API):
+
+ def get_routes(self):
+ return [
+ {
+ 'method': 'GET',
+ 'path': '/status',
+ 'func': self._status,
+ 'scopes': ['status'],
+ },
+ {
+ 'method': 'GET',
+ 'path': '/hello/<name>',
+ 'func': self._hello,
+ 'scopes': [],
+ },
+ ]
+
+ def _status(self):
+ return {
+ 'queue': [],
+ 'running': [],
+ 'finished': [],
+ }
+
+ def _hello(self, name=None):
+ return 'hello {}\n'.format(name)
+
+
+def get_key_from_file(filename):
+ with open(filename) as f:
+ key_text = f.read()
+ return Crypto.PublicKey.RSA.importKey(key_text)
+
+
+def main():
+ logging.basicConfig(
+ filename='api.log',
+ level=logging.DEBUG,
+ format='%(levelname)s %(message)s')
+
+ filename = sys.argv[1]
+ key = get_key_from_file(filename)
+
+ app = bottle.Bottle()
+ api = Controller(app, key)
+ app.run(host='localhost', port=2222)
+
+main()
diff --git a/create-token b/create-token
new file mode 100755
index 0000000..e2e9fbb
--- /dev/null
+++ b/create-token
@@ -0,0 +1,44 @@
+#!/usr/bin/python3
+# Copyright (C) 2017 Lars Wirzenius
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import sys
+import time
+
+import Crypto.PublicKey.RSA
+
+import apifw
+
+
+filename = sys.argv[1]
+iss = sys.argv[2]
+aud = sys.argv[3]
+scopes = ' '.join(sys.argv[4].split())
+
+key_text = open(filename, 'r').read()
+key = Crypto.PublicKey.RSA.importKey(key_text)
+
+now = time.time()
+claims = {
+ 'iss': iss,
+ 'sub': 'subject-uuid',
+ 'aud': aud,
+ 'exp': now + 3600,
+ 'scope': scopes,
+}
+
+token = apifw.create_token(claims, key)
+sys.stdout.write(token.decode('ascii'))
diff --git a/generate-rsa-key b/generate-rsa-key
new file mode 100755
index 0000000..d73ba0d
--- /dev/null
+++ b/generate-rsa-key
@@ -0,0 +1,34 @@
+#!/usr/bin/python3
+# Copyright (C) 2017 Lars Wirzenius
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import sys
+
+import Crypto.PublicKey.RSA
+
+
+RSA_KEY_BITS = 4096 # A nice, currently safe length
+
+key = Crypto.PublicKey.RSA.generate(RSA_KEY_BITS)
+
+filename = sys.argv[1]
+
+def write(filename, byts):
+ with open(filename, 'w') as f:
+ f.write(byts.decode('ascii'))
+
+write(filename, key.exportKey('PEM'))
+write(filename + '.pub', key.exportKey('OpenSSH'))