add authentication

This commit is contained in:
Anatol Ulrich 2023-10-04 21:03:20 +02:00
parent 89368ded2a
commit fa52233404
8 changed files with 428 additions and 0 deletions

33
app.py Normal file
View File

@ -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")

83
auth.py Normal file
View File

@ -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

109
config.py Normal file
View File

@ -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)

14
config.toml Normal file
View File

@ -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"

11
consts.py Normal file
View File

@ -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'

89
main.py Executable file
View File

@ -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()

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
fastapi
python-jose[cryptography]
toml
strawberry-graphql[fastapi]
uvicorn[standard]
coloredlogs
pydantic
httpx

81
schema.py Normal file
View File

@ -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)