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