diff options
author | Lars Wirzenius <liw@liw.fi> | 2018-02-09 13:53:33 +0200 |
---|---|---|
committer | Lars Wirzenius <liw@liw.fi> | 2018-02-09 13:56:43 +0200 |
commit | f1f291b270b96fe1511286cb807f02c9741b0d71 (patch) | |
tree | 509a7f697685e9282fbdc6838fb037ff6cb5e4dd /qvisqve | |
parent | 3b208da0461f5a129fcbc527fbfdd9ed8309d077 (diff) | |
download | qvisqve-f1f291b270b96fe1511286cb807f02c9741b0d71.tar.gz |
Rename: to Qvisqve
Diffstat (limited to 'qvisqve')
-rw-r--r-- | qvisqve/__init__.py | 31 | ||||
-rw-r--r-- | qvisqve/api.py | 49 | ||||
-rw-r--r-- | qvisqve/app.py | 76 | ||||
-rw-r--r-- | qvisqve/backend.py | 20 | ||||
-rw-r--r-- | qvisqve/log_setup.py | 54 | ||||
-rw-r--r-- | qvisqve/responses.py | 59 | ||||
-rw-r--r-- | qvisqve/router.py | 23 | ||||
-rw-r--r-- | qvisqve/token.py | 72 | ||||
-rw-r--r-- | qvisqve/token_router.py | 113 | ||||
-rw-r--r-- | qvisqve/version.py | 2 | ||||
-rw-r--r-- | qvisqve/version_router.py | 36 |
11 files changed, 535 insertions, 0 deletions
diff --git a/qvisqve/__init__.py b/qvisqve/__init__.py new file mode 100644 index 0000000..a1d80f9 --- /dev/null +++ b/qvisqve/__init__.py @@ -0,0 +1,31 @@ +# 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/>. + +from .version import __version__, __version_info__ +from .responses import ( + bad_request_response, + created_response, + ok_response, + unauthorized_response, +) +from .log_setup import setup_logging, log +from .token import TokenGenerator + +from .router import Router +from .version_router import VersionRouter +from .token_router import TokenRouter + +from .api import API +from .app import create_app diff --git a/qvisqve/api.py b/qvisqve/api.py new file mode 100644 index 0000000..2dee954 --- /dev/null +++ b/qvisqve/api.py @@ -0,0 +1,49 @@ +# 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 qvisqve + + +class API: + + def __init__(self, config): + self._config = config + + def find_missing_route(self, path): + qvisqve.log.log('info', msg_text='find_missing_route', path=path) + + routers = [ + qvisqve.VersionRouter(), + qvisqve.TokenRouter( + self._create_token_generator(), self._get_clients()), + ] + + routes = [] + for router in routers: + routes.extend(router.get_routes()) + + return routes + + def _create_token_generator(self): + tg = qvisqve.TokenGenerator() + cfg = self._config + tg.set_issuer(cfg['token-issuer']) + tg.set_lifetime(cfg['token-lifetime']) + tg.set_signing_key(cfg['token-private-key']) + return tg + + def _get_clients(self): + return self._config.get('clients', {}) diff --git a/qvisqve/app.py b/qvisqve/app.py new file mode 100644 index 0000000..e608a39 --- /dev/null +++ b/qvisqve/app.py @@ -0,0 +1,76 @@ +# Copyright (C) 2017-2018 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 os + + +import apifw +import slog +import yaml + + +import qvisqve + + +DEFAULT_CONFIG_FILE = '/dev/null' + + +def dict_logger(log, stack_info=None): + qvisqve.log.log(exc_info=stack_info, **log) + + +def read_config(filename): + with open(filename) as f: + return yaml.safe_load(f) + + +def check_config(cfg): + for key in cfg: + if cfg[key] is None: + raise Exception('Configration %s should not be None' % key) + + +_counter = slog.Counter() + + +def counter(): + new_context = 'HTTP transaction {}'.format(_counter.increment()) + qvisqve.log.set_context(new_context) + + +default_config = { + 'log': [], + 'token-issuer': None, + 'token-public-key': None, + 'token-private-key': None, + 'token-lifetime': None, + 'clients': None, +} + + +def create_app(): + config_filename = os.environ.get('QVISQVE_CONFIG', DEFAULT_CONFIG_FILE) + actual_config = read_config(config_filename) + config = dict(default_config) + config.update(actual_config or {}) + if 'token-audience' not in config: + config['token-audience'] = config.get('token-issuer') + check_config(config) + qvisqve.setup_logging(config) + qvisqve.log.log('info', msg_text='Qvisqve starting') + + api = qvisqve.API(config) + return apifw.create_bottle_application(api, counter, dict_logger, config) diff --git a/qvisqve/backend.py b/qvisqve/backend.py new file mode 100644 index 0000000..3db3225 --- /dev/null +++ b/qvisqve/backend.py @@ -0,0 +1,20 @@ +# Copyright (C) 2017-2018 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 qvisqve + + +app = qvisqve.create_app() diff --git a/qvisqve/log_setup.py b/qvisqve/log_setup.py new file mode 100644 index 0000000..e32137c --- /dev/null +++ b/qvisqve/log_setup.py @@ -0,0 +1,54 @@ +# 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 slog + + +def drop_get_message(log_obj): + # These are useless and annoying in gunicorn log messages. + if 'getMessage' in log_obj: + del log_obj['getMessage'] + return log_obj + + +# We are probably run under gunicorn, which sets up logging via the +# logging library. Hijack that so actual logging happens via the slog +# library. For this, we need to know the logger names gunicorn uses. +gunicorn_loggers = ['gunicorn.access', 'gunicorn.error'] + + +# This sets up a global log variable that doesn't actually log +# anything anywhere. This is useful so that code can unconditionally +# call log.log(...) from anywhere. See setup_logging() for setting up +# actual logging to somewhere persistent. + +log = slog.StructuredLog() +log.add_log_writer(slog.NullSlogWriter(), slog.FilterAllow()) +log.add_log_massager(drop_get_message) +slog.hijack_logging(log, logger_names=gunicorn_loggers) + + +def setup_logging(config): + for target in config.get('log', []): + setup_logging_to_file(target, slog.FilterAllow()) + + +def setup_logging_to_file(target, rule): + writer = slog.FileSlogWriter() + writer.set_filename(target['filename']) + if 'max_bytes' in target: + writer.set_max_file_size(target['max_bytes']) + log.add_log_writer(writer, rule) diff --git a/qvisqve/responses.py b/qvisqve/responses.py new file mode 100644 index 0000000..281ec43 --- /dev/null +++ b/qvisqve/responses.py @@ -0,0 +1,59 @@ +# 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 apifw + + +def response(status, body, headers): + return apifw.Response( + { + 'status': status, + 'body': body, + 'headers': headers, + } + ) + + +def ok_response(body, headers=None): + if headers is None: + headers = {} + if 'Content-Type' not in headers: + headers.update({ + 'Content-Type': 'application/json', + }) + return response(apifw.HTTP_OK, body, headers) + + +def created_response(body, location): + headers = { + 'Content-Type': 'application/json', + 'Location': location, + } + return response(apifw.HTTP_CREATED, body, headers) + + +def bad_request_response(body): + headers = { + 'Content-Type': 'text/plain', + } + return response(apifw.HTTP_BAD_REQUEST, body, headers) + + +def unauthorized_response(body): + headers = { + 'Content-Type': 'text/plain', + } + return response(apifw.HTTP_UNAUTHORIZED, body, headers) diff --git a/qvisqve/router.py b/qvisqve/router.py new file mode 100644 index 0000000..9f171b0 --- /dev/null +++ b/qvisqve/router.py @@ -0,0 +1,23 @@ +# 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/>. + + +class Router: + + def __init__(self): + pass + + def get_routes(self): + raise NotImplementedError() diff --git a/qvisqve/token.py b/qvisqve/token.py new file mode 100644 index 0000000..3b343c2 --- /dev/null +++ b/qvisqve/token.py @@ -0,0 +1,72 @@ +#!/usr/bin/python3 +# Copyright (C) 2017-2018 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 time + + +import Crypto.PublicKey.RSA +import jwt + + +import qvisqve + + +class TokenGenerator: + + _algorithm = 'RS512' + + def __init__(self): + self._issuer = None + self._lifetime = None + self._key = None + + def set_issuer(self, issuer): + self._issuer = issuer + qvisqve.log.log('info', msg_text='Set issuer', issuer=issuer) + + def set_lifetime(self, lifetime): + self._lifetime = lifetime + qvisqve.log.log( + 'info', msg_text='Set token lifetime', lifetime=lifetime) + + def set_signing_key(self, key): + imported_key = Crypto.PublicKey.RSA.importKey(key) + self._key = imported_key.exportKey('PEM') + qvisqve.log.log( + 'info', msg_text='Set signing key', key=self._key, + orig_key=key, imported_key=imported_key) + + def new_token(self, audience, scope): + assert self._issuer is not None + assert self._lifetime is not None + assert self._key is not None + + now = time.time() + claims = { + 'iss': self._issuer, + 'sub': '', + 'aud': audience, + 'exp': now + self._lifetime, + 'scope': scope, + } + + token = jwt.encode( + claims, + self._key, + algorithm=self._algorithm) + + return token.decode('ascii') diff --git a/qvisqve/token_router.py b/qvisqve/token_router.py new file mode 100644 index 0000000..9acc924 --- /dev/null +++ b/qvisqve/token_router.py @@ -0,0 +1,113 @@ +# Copyright (C) 2018 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 urllib.parse + + +import bottle + + +import qvisqve +import qvisqve_secrets + + +class TokenRouter(qvisqve.Router): + + def __init__(self, token_generator, clients): + super().__init__() + self._generator = token_generator + self._clients = Clients(clients) + + def get_routes(self): + return [ + { + 'method': 'POST', + 'path': '/token', + 'callback': self._create_token, + 'needs-authorization': False, + }, + ] + + def _create_token(self, content_type, body, **kwargs): + qvisqve.log.log('xxx', body=body, kwargs=kwargs) + + if content_type != 'application/x-www-form-urlencoded': + return qvisqve.bad_request_response('Wrong content type') + + client_id, client_secret = bottle.request.auth + if not self._clients.is_correct_secret(client_id, client_secret): + return qvisqve.unauthorized_response('Unauthorized') + + params = self._get_form_params(body) + + grant_type = self._get_grant_type(params) + if grant_type != 'client_credentials': + return qvisqve.bad_request_response('Wrong grant type') + + scope = self._get_scope(params) + if scope is None: + return qvisqve.bad_request_response('Bad scope') + + allowed = self._clients.get_allowed_scopes(client_id) + scope = ' '.join( + s + for s in scope.split() + if s in allowed + ) + + token = self._generator.new_token(client_id, scope) + return qvisqve.ok_response({ + 'access_token': token, + 'token_type': 'bearer', + 'scope': scope, + }) + + def _get_form_params(self, body): + body = body.decode('UTF-8') + return urllib.parse.parse_qs(body) + + def _get_grant_type(self, params): + grant_type = params.get('grant_type') + if len(grant_type) == 1: + return grant_type[0] + return None + + def _get_scope(self, params): + scope = params.get('scope', []) + if len(scope) > 1: + return None + if scope: + return scope[0] + return '' + + +class Clients: + + def __init__(self, clients): + self._clients = clients + self._hasher = qvisqve_secrets.SecretHasher() + + def is_correct_secret(self, client_id, cleartext): + client = self._get_client(client_id) + secret = client.get('client_secret') + return secret and self._hasher.is_correct(secret, cleartext) + + def get_allowed_scopes(self, client_id): + client = self._get_client(client_id) + return client.get('allowed_scopes', []) + + def _get_client(self, client_id): + return self._clients.get(client_id, {}) diff --git a/qvisqve/version.py b/qvisqve/version.py new file mode 100644 index 0000000..456773e --- /dev/null +++ b/qvisqve/version.py @@ -0,0 +1,2 @@ +__version__ = "0.8+git" +__version_info__ = (0, 8, '+git') diff --git a/qvisqve/version_router.py b/qvisqve/version_router.py new file mode 100644 index 0000000..311d505 --- /dev/null +++ b/qvisqve/version_router.py @@ -0,0 +1,36 @@ +# 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 qvisqve + + +class VersionRouter(qvisqve.Router): + + def get_routes(self): + return [ + { + 'method': 'GET', + 'path': '/version', + 'callback': self._version, + 'needs-authorization': False, + }, + ] + + def _version(self, *args, **kwargs): + version = { + 'version': qvisqve.__version__, + } + return qvisqve.ok_response(version) |