add authentication
This commit is contained in:
parent
89368ded2a
commit
fa52233404
33
app.py
Normal file
33
app.py
Normal 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
83
auth.py
Normal 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
109
config.py
Normal 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
14
config.toml
Normal 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
11
consts.py
Normal 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
89
main.py
Executable 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
8
requirements.txt
Normal file
@ -0,0 +1,8 @@
|
||||
fastapi
|
||||
python-jose[cryptography]
|
||||
toml
|
||||
strawberry-graphql[fastapi]
|
||||
uvicorn[standard]
|
||||
coloredlogs
|
||||
pydantic
|
||||
httpx
|
81
schema.py
Normal file
81
schema.py
Normal 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)
|
Loading…
Reference in New Issue
Block a user