# 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 . import json import re import time import bottle import Crypto.PublicKey.RSA import jwt import apifw class BottleLoggingPlugin(apifw.HttpTransaction): # This a binding between Bottle and apifw.HttpTransaction, as a # Bottle plugin. We arrange the perform_transaction method to be # called with the right arguments, in particular the callback # function to call. We also provide Bottle specific methods to log # the HTTP request and response, and to amend the response by # adding a Date header. def apply(self, callback, route): def wrapper(*args, **kwargs): try: return self.perform_transaction(callback, *args, **kwargs) except bottle.HTTPError as e: self._log_error(e) raise e except BaseException as e: self._log_error(e) raise bottle.HTTPError(500, body=str(e)) return wrapper def amend_response(self): rfc822 = time.strftime('%a, %d %b %Y %H:%M:%S %z') bottle.response.set_header('Date', rfc822) def construct_request_log(self): r = bottle.request return { 'method': r.method, 'path': r.path, } def construct_response_log(self): r = bottle.response return { 'status': r.status_code, 'text': r.body, } class BottleAuthorizationPlugin: route_pat = re.compile(r'<[^>]*>') def __init__(self): self.pubkey = None self.iss = None self.aud = None self._authz_routes = set() self._logger = lambda log, **kwargs: None def set_dict_logger(self, logger): self._logger = logger def set_token_signing_public_key(self, pubkey): self.pubkey = Crypto.PublicKey.RSA.importKey(pubkey) def set_expected_issuer(self, iss): self.iss = iss def set_expected_audience(self, aud): self.aud = aud def set_route_authorization(self, route): key = self.route_key(route) if route.get('needs-authorization', True): self._authz_routes.add(key) self._logger({ 'msg_type': 'info', 'msg_text': 'Route does DOES need authorization', 'route': key, }) else: self._logger({ 'msg_type': 'info', 'msg_text': 'Route does NOT need authorization', 'route': key, }) def route_key(self, route): # route can be a dict (from find_missing_route), or a # bottle.Route object. method = route.get('method', 'GET') path = route.get('path') rule = route.get('rule') scope = self.get_scope_for_route(method, path or rule) return (method, scope) def apply(self, callback, route): def wrapper(*args, **kwargs): if self.needs_authorization(route): claims = self.get_token_claims() kwargs['claims'] = claims self.assert_authorized(route, claims) return callback(*args, **kwargs) return wrapper def needs_authorization(self, route): key = self.route_key(route) return key in self._authz_routes def get_token_claims(self): value = self.get_authorization_header(bottle.request) token = self.parse_authorization_header(value) claims = self.parse_token(token) return claims def assert_authorized(self, route, claims): self.check_issuer(claims) if not self.scope_allows_route(claims['scope'], route): self.raise_forbidden( 'insufficient_scope', 'scope does not allow route') def get_authorization_header(self, request): value = request.get_header('Authorization', '') if not value: self.raise_unauthorized( 'no_authorization', 'No Authorization header') return value def parse_authorization_header(self, value): words = value.split() if len(words) != 2 or words[0].lower() != 'bearer': self.raise_unauthorized( 'bad_authorization', 'Authorization should be "Bearer TOKEN"') return words[1] def parse_token(self, token): try: token = apifw.decode_token(token, self.pubkey, audience=self.aud) return token except jwt.InvalidTokenError as e: self.raise_unauthorized('invalid_token', str(e)) def check_issuer(self, claims): if claims['iss'] != self.iss: self.raise_unauthorized( 'bad_issuer', 'Expected issuer %s, got %s' % (self.iss, claims['iss'])) def scope_allows_route(self, claim_scopes, route): scopes = claim_scopes.split(' ') route_scope = self.get_scope_for_route(route['method'], route['rule']) if route_scope in scopes: return True self._logger({ 'msg_type': 'error', 'msg_text': 'Route scope is NOT in scopes', 'route_scope': route_scope, 'scopes': scopes, }) return False def get_scope_for_route(self, method, rule): scope = re.sub(self.route_pat, 'id', rule) scope = scope.replace('/', '_') scope = 'uapi%s_%s' % (scope, method) return scope.lower() def raise_unauthorized(self, error, explanation): headers = { 'WWW-Authenticate': 'Bearer error="{}"'.format(error), } raise bottle.HTTPError( apifw.HTTP_UNAUTHORIZED, body=explanation, headers=headers) def raise_forbidden(self, error, explanation): headers = { 'WWW-Authenticate': 'Bearer error="{}"'.format(error), } raise bottle.HTTPError( apifw.HTTP_FORBIDDEN, body=explanation, headers=headers) class BottleApplication: # Provide the interface to bottle.Bottle that we need. # Specifically, we set up a hook to call the # Api.find_missing_route method when a request would otherwise # return 404, and we add the routes it returns to Bottle so that # they are there for this and future requests. def __init__(self, bottleapp, api): self._bottleapp = bottleapp self._bottleapp.add_hook('before_request', self._add_missing_route) self._api = api self._authz = None def add_plugin(self, plugin): self._bottleapp.install(plugin) def set_authorization_plugin(self, plugin): self._authz = plugin def _add_missing_route(self): try: self._bottleapp.match(bottle.request.environ) except bottle.HTTPError: routes = self._api.find_missing_route(bottle.request.path) if routes: for route in routes: if self._big_blob_route(route): callback = self._callback_with_big_blob( route['callback']) else: callback = self._callback_with_body(route['callback']) route_dict = { 'method': route.get('method', 'GET'), 'path': route['path'], 'callback': callback, } self._bottleapp.route(**route_dict) self._authz.set_route_authorization(route) else: raise def _big_blob_route(self, route): return route.get('big-blobs', False) def add_routes_for_resource_type(self, rt): routes = self._api.find_missing_route(rt.get_path()) for route in routes: callback = self._callback_with_body(route['callback']) route_dict = { 'method': route.get('method', 'GET'), 'path': route['path'], 'callback': callback, } self._bottleapp.route(**route_dict) self._authz.set_route_authorization(route) def _callback_with_big_blob(self, callback): def wrapper(*args, **kwargs): kwargs['raw_uri_path'] = bottle.request.environ.get('RAW_URI', '') content_type = self._get_content_type() body = self._read_body response = callback(content_type, body, *args, **kwargs) return bottle.HTTPResponse( status=response['status'], body=response['body'], headers=response['headers']) return wrapper def _get_content_type(self): return bottle.request.get_header('Content-Type') def _read_body(self, max_bytes): read = bottle.request.environ['wsgi.input'].read for part in bottle.request._iter_body(read, max_bytes): yield part def _callback_with_body(self, callback): def wrapper(*args, **kwargs): kwargs['raw_uri_path'] = bottle.request.environ.get('RAW_URI', '') content_type, body = self._get_request_body() response = callback(content_type, body, *args, **kwargs) return bottle.HTTPResponse( status=response['status'], body=response['body'], headers=response['headers']) return wrapper def _get_request_body(self): raw_body = b''.join(self._read_body(1024**2)) if bottle.request.method in ('POST', 'PUT'): if len(raw_body) == 0: raise bottle.HTTPError( apifw.HTTP_LENGTH_REQUIRED, body='Empty body not allowed for PUT/POST') json_type = 'application/json' content_type = self._get_content_type() if content_type != json_type: return content_type, raw_body try: text_body = raw_body.decode('UTF-8') parsed = json.loads(text_body) return content_type, parsed except json.decoder.JSONDecodeError as e: raise bottle.HTTPError(status=apifw.HTTP_BAD_REQUEST, body=str(e)) def create_bottle_application( api, counter, logger, config, resource_types=None): # Create a new bottle.Bottle application, set it up, and return it # so that gunicorn can execute it from the main program. bottleapp = bottle.Bottle() app = BottleApplication(bottleapp, api) plugin = BottleLoggingPlugin() plugin.set_transaction_counter(counter) if logger: plugin.set_dict_logger(logger) app.add_plugin(plugin) authz = BottleAuthorizationPlugin() if logger: authz.set_dict_logger(logger) authz.set_token_signing_public_key(config['token-public-key']) authz.set_expected_issuer(config['token-issuer']) authz.set_expected_audience(config['token-audience']) app.add_plugin(authz) app.set_authorization_plugin(authz) for rt in resource_types or []: app.add_routes_for_resource_type(rt) return bottleapp