From fa522334045302b5d45a8c61f7c1f24075f66d2f Mon Sep 17 00:00:00 2001 From: Anatol Ulrich Date: Wed, 4 Oct 2023 21:03:20 +0200 Subject: [PATCH] add authentication --- app.py | 33 ++++++++++++++ auth.py | 83 ++++++++++++++++++++++++++++++++++++ config.py | 109 +++++++++++++++++++++++++++++++++++++++++++++++ config.toml | 14 ++++++ consts.py | 11 +++++ main.py | 89 ++++++++++++++++++++++++++++++++++++++ requirements.txt | 8 ++++ schema.py | 81 +++++++++++++++++++++++++++++++++++ 8 files changed, 428 insertions(+) create mode 100644 app.py create mode 100644 auth.py create mode 100644 config.py create mode 100644 config.toml create mode 100644 consts.py create mode 100755 main.py create mode 100644 requirements.txt create mode 100644 schema.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..7fa44de --- /dev/null +++ b/app.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 + +""" +Basebox microservices example app + +this defines an `app:app` module which is loaded from `main.py` or directly via `uvicorn app:app`. +in the latter case configuration can only be passed via OS environment or an env file (`--env-file`), not TOML. +""" + +# stdlib imports +import logging + +# dependencies imports +from fastapi import FastAPI +from strawberry.fastapi import GraphQLRouter + + +# app imports +import schema +import consts + +import auth + +logger = logging.getLogger(consts.LOG_ROOT) + +logger.debug('starting app') + +# configure GraphQL with `schema.schema` and additional auth context +graphql_app = GraphQLRouter(schema.schema, context_getter=auth.get_context) + +# create app with GraphQL routing +app = FastAPI() +app.include_router(graphql_app, prefix="/graphql") diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..ae66ab6 --- /dev/null +++ b/auth.py @@ -0,0 +1,83 @@ +from functools import cached_property +import logging + +import httpx +from jose import jws, jwt +import strawberry +from strawberry.fastapi import BaseContext +from strawberry.types import Info as _Info +from strawberry.types.info import RootValueType + +import consts +import config + +# valid signing algorithms. This mostly serves to avoid the 'none' exploit, add more algorithms as necessary +VALID_ALGS = ['HS256', 'RS256'] + +logger = logging.getLogger(consts.LOG_ROOT) +PERMISSIONS_KEY = config.str_val('AUTH_PERMISSIONS_KEY') +OLS_PREFIX = config.str_val('AUTH_OLS_PREFIX') + +logger.debug('loading JWKS data') + +# fetch IdP configuration, extract JWKS url from there, and load its contents +idp_url = config.str_val('AUTH_IDP_URL') +config_url = idp_url + '/.well-known/openid-configuration' + +logger.debug('loading openid configuration from %s', config_url) +config = httpx.get(config_url).json() + +jwks_url = config['jwks_uri'] +logger.debug('loading JWKS data from %s', jwks_url) +jwks = httpx.get(jwks_url).json() + + +class Context(BaseContext): + @cached_property + def token(self) -> str | None: + if not self.request: + return None + + token = self.request.headers.get("Authorization", None) + if token: + token = token.split("Bearer ")[1] + return token + + +Info = _Info[Context, RootValueType] + + +async def get_context() -> Context: + return Context() + + +class SecurityException(Exception): + pass + + +def validate_permissions(token: str | None, method_name: str) -> bool: + if token is None: + logger.info("no token") + return False + + headers = jwt.get_unverified_headers(token) + logger.debug('headers: %s', headers) + + claims = jwt.get_unverified_claims(token) + logger.debug('claims: %s', claims) + + permissions = claims.get("basebox/permissions", []) + algorithm = headers.get('alg') + if not algorithm in VALID_ALGS: + logger.info('Invalid signing algorithm: %s', algorithm) + return False + + token_valid = jws.verify(token=token, key=jwks, algorithms=algorithm) + if not token_valid: + logger.info("invalid token") + return False + + namespaced_method = f'{OLS_PREFIX}{method_name}' + logger.debug("verify %s <> %s", namespaced_method, permissions) + has_permission = namespaced_method in permissions + return has_permission diff --git a/config.py b/config.py new file mode 100644 index 0000000..6d3c249 --- /dev/null +++ b/config.py @@ -0,0 +1,109 @@ +""" +Application configuration + +values can be configured in the OS environment via KEY_NAME=... +or in a toml file using + +[section] +foo = ... +bar_baz = ... + +in the latter case, the section and name are uppercased and joined with `_`, +then inserted into the OS environment (in this case, resulting in +SECTION_FOO=... and SECTION_BAR_BAZ=... ). +The OS environment takes precedence - if a key is found in both env and config, +the config setting will be ignored. +""" +import tomllib +import logging +from os import environ +from functools import wraps + +import consts + +logger = logging.getLogger(consts.LOG_ROOT) + + +def load(file): + """ + Load configuration from toml and add to environment + """ + + if file is None: + logger.debug('no config file provided.') + else: + logger.debug('loading config') + with open(file, 'rb') as fh: + config = tomllib.load(fh) + for k, v in config.items(): + for k_inner, v_inner in v.items(): + effective_key = f'{k.upper()}_{k_inner.upper()}' + if effective_key in environ: + logger.warning( + '%s already exists in os environment, skipping', effective_key) + else: + environ[effective_key] = str(v_inner) + logger.debug('raw: %s = %s', effective_key, + environ[effective_key]) + + +def env(key, default=None): + """ + retrieve a key from the OS environment. + throws `KeyError` when the key is not found and no default fallback is provided. + """ + if default is None: + return environ[key] + return environ.get(key, default=default) + + +def logged(f): + """ + logging wrapper for resolved configuration settings + """ + @wraps(f) + def decorated(*args, **kwargs): + res = f(*args, **kwargs) + logger.debug('resolved: %s = %s', args[0], res) + return res + return decorated + + +@logged +def int_val(key, default=None): + """ + retrieve configuration value as int + """ + + return int(env(key, default=default)) + + +def strtobool(val): + """Convert a string representation of truth to true (1) or false (0). + True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values + are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if + 'val' is anything else. + """ + val = val.lower() + if val in ("y", "yes", "t", "true", "on", "1"): + return True + elif val in ("n", "no", "f", "false", "off", "0"): + return False + else: + raise ValueError(f'Invalid bool string: {val}') + + +@logged +def bool_val(key, default=None): + """ + retrieve configuration value as bool + """ + return strtobool(env(key, default=default)) + + +@logged +def str_val(key, default=None): + """ + retrieve configuration value as bool + """ + return env(key, default=default) diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..e68fe7c --- /dev/null +++ b/config.toml @@ -0,0 +1,14 @@ +[server] +port = 8891 +# automatically reload on source file changes. Default: false +autoreload = true + +[auth] +# realm for identity provider, for loading key store data +idp_url = "https://kcdev.basebox.io:8443/realms/test-runner" + +# prefix for permission values, resulting in a check against e.g. "allow::bb::operation::orderPizza" +ols_prefix = "allow::bb::operation::" + +# permissions key name in token claims +permissions_key = "basebox/permissions" diff --git a/consts.py b/consts.py new file mode 100644 index 0000000..cfd9cf5 --- /dev/null +++ b/consts.py @@ -0,0 +1,11 @@ +"""global constants""" + +# application log hierarchy root +# +# an empty root will configure logging for all packages +# use this to debug issues with underlying modules +# LOG_ROOT = '' +LOG_ROOT = 'bb.mspy' + +# default log level +DEFAULT_LOG_LEVEL = 'debug' diff --git a/main.py b/main.py new file mode 100755 index 0000000..8867fe9 --- /dev/null +++ b/main.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 + +""" +Basebox microservices example app + +this defines an `app:app` module which is loaded from `main.py` +or directly via `uvicorn app:app`. + +in the latter case configuration can only be passed +via OS environment or an env file (`--env-file`), not TOML. + +""" + +# stdlib imports +from os import environ +import argparse +import logging + +# dependencies imports +import coloredlogs +import uvicorn + +# app imports +import config +import consts + + +def setup_logging(): + """ + Configure logging from LOG_LEVEL environment variable, defaulting to DEBUG. + This intentionally does not use the config system to enable log messages + from config initialization. + + Since logging is taken over by uvicorn, this will only be effective during initial startup. + + :returns: the effective log level + """ + level = environ.get('LOG_LEVEL', consts.DEFAULT_LOG_LEVEL) + logger = logging.getLogger(consts.LOG_ROOT) + coloredlogs.install(level=level, logger=logger) + return level + + +def parse_args(): + """ + parse command line arguments + """ + parser = argparse.ArgumentParser( + description='Basebox microservice example') + parser.add_argument('-c', '--config-file', type=str, + help='config file') + return parser.parse_args() + + +def main(): + """ + application entry point + """ + + # setup logging + log_level = setup_logging() + + # load config + args = parse_args() + config.load(args.config_file) + + # inject our application log root into uvicorn's own log config + # + # discussion: + # https://github.com/tiangolo/fastapi/discussions/7457 + # https://github.com/encode/uvicorn/issues/491 + log_config = uvicorn.config.LOGGING_CONFIG + log_config["loggers"][consts.LOG_ROOT] = { + "level": log_level.upper(), + "handlers": [ + "default" + ], + } + + # start server + uvicorn.run('app:app', + log_config=log_config, + port=config.int_val('SERVER_PORT'), + reload=config.bool_val( + 'SERVER_AUTORELOAD', 'False')) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..86b6c9e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi +python-jose[cryptography] +toml +strawberry-graphql[fastapi] +uvicorn[standard] +coloredlogs +pydantic +httpx \ No newline at end of file diff --git a/schema.py b/schema.py new file mode 100644 index 0000000..6e7c19f --- /dev/null +++ b/schema.py @@ -0,0 +1,81 @@ +""" +GraphQL schema definitions +""" + +# stdlib imports +from random import randint +from datetime import datetime +import typing +import logging + +# dependencies imports +import strawberry + +# app imports +import consts +import auth + + +logger = logging.getLogger(consts.LOG_ROOT) + + +@strawberry.type +class Pizza: + """ + Pizza + """ + name: str + toppings: typing.List[str] + + +@strawberry.type +class OrderInfo: + """ + Information about a pizza order + """ + id: str + deliveryTime: str + bakerName: str + pizza: Pizza + + +@strawberry.type +class Mutation: + """ + Operation to order a pizza + """ + @strawberry.mutation + def order_pizza(self, name: str, toppings: typing.List[str], info: auth.Info) -> OrderInfo: + """ + Order a pizza with a name and list of toppings + """ + logger.debug("orderPizza name=%s toppings=%s", name, toppings) + token = info.context.token + has_permission = auth.validate_permissions(token, "orderPizza") + if not has_permission: + raise auth.SecurityException("Permission denied") + return OrderInfo( + id=str(randint(1, 100000)), + deliveryTime=str(datetime.now()), + bakerName="Domino's", + pizza=Pizza(name=name, toppings=toppings) + ) + + +def empty(): + """ + empty resolver for stub query (see below) + """ + + +@strawberry.type +class Query: + """ + `strawberry.Schema` requires at least one `Query`, so configure a stub + see https://strawberry.rocks/docs/general/mutations + """ + stub: str = strawberry.field(resolver=empty) + + +# query= is required: https://strawberry.rocks/docs/general/mutations +schema = strawberry.Schema(query=Query, mutation=Mutation)