Compare commits

...

3 Commits

Author SHA1 Message Date
eceff6128b [WIP] Authentication
All checks were successful
Build on push / prepare (push) Successful in 9s
Build on push / core (push) Successful in 19s
Build on push / query (push) Successful in 19s
Build on push / dependency (push) Successful in 17s
Build on push / application (push) Successful in 16s
Build on push / mail (push) Successful in 16s
Build on push / translation (push) Successful in 18s
Build on push / database (push) Successful in 22s
Build on push / auth (push) Successful in 15s
Build on push / api (push) Successful in 13s
2025-09-19 23:01:41 +02:00
17dfb245bf Minor cleanup 2025-09-19 21:54:08 +02:00
4f698269b5 Fixed api build
All checks were successful
Build on push / prepare (push) Successful in 9s
Build on push / core (push) Successful in 18s
Build on push / query (push) Successful in 17s
Build on push / dependency (push) Successful in 18s
Build on push / application (push) Successful in 15s
Build on push / database (push) Successful in 18s
Build on push / translation (push) Successful in 18s
Build on push / mail (push) Successful in 19s
Build on push / auth (push) Successful in 15s
Build on push / api (push) Successful in 14s
2025-09-19 21:12:33 +02:00
15 changed files with 182 additions and 23 deletions

View File

@@ -16,7 +16,7 @@ jobs:
uses: ./.gitea/workflows/package.yaml uses: ./.gitea/workflows/package.yaml
needs: [ prepare, application, auth, core, dependency ] needs: [ prepare, application, auth, core, dependency ]
with: with:
working_directory: src/cpl-application working_directory: src/cpl-api
secrets: inherit secrets: inherit
application: application:

61
install.sh Normal file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
# Find and combine requirements from src/cpl-*/requirements.txt,
# filtering out lines whose *package name* starts with "cpl-".
# Works with pinned versions, extras, markers, editable installs, and VCS refs.
shopt -s nullglob
req_files=(src/cpl-*/requirements.txt)
if ((${#req_files[@]} == 0)); then
echo "No requirements files found at src/cpl-*/requirements.txt" >&2
exit 1
fi
tmp_combined="$(mktemp)"
trap 'rm -f "$tmp_combined"' EXIT
# Concatenate, trim comments/whitespace, filter out cpl-* packages, dedupe.
# We keep non-package options/flags/constraints as-is.
awk '
function trim(s){ sub(/^[[:space:]]+/,"",s); sub(/[[:space:]]+$/,"",s); return s }
{
line=$0
# drop full-line comments and strip inline comments
if (line ~ /^[[:space:]]*#/) next
sub(/#[^!].*$/,"",line) # strip trailing comment (simple heuristic)
line=trim(line)
if (line == "") next
# Determine the package *name* even for "-e", extras, pins, markers, or VCS "@"
e = line
sub(/^-e[[:space:]]+/,"",e) # remove editable prefix
# Tokenize up to the first of these separators: space, [ < > = ! ~ ; @
token = e
sub(/\[.*/,"",token) # remove extras quickly
n = split(token, a, /[<>=!~;@[:space:]]/)
name = tolower(a[1])
# If the first token (name) starts with "cpl-", skip this requirement
if (name ~ /^cpl-/) next
print line
}
' "${req_files[@]}" | sort -u > "$tmp_combined"
if ! [ -s "$tmp_combined" ]; then
echo "Nothing to install after filtering out cpl-* packages." >&2
exit 0
fi
echo "Installing dependencies (excluding cpl-*) from:"
printf ' - %s\n' "${req_files[@]}"
echo
echo "Final set to install:"
cat "$tmp_combined"
echo
# Use python -m pip for reliability; change to python3 if needed.
python -m pip install -r "$tmp_combined"

View File

@@ -1,9 +1,15 @@
from http.client import HTTPException from http.client import HTTPException
from starlette.responses import JSONResponse
class APIError(HTTPException): class APIError(HTTPException):
status_code = 500 status_code = 500
@classmethod
def response(cls):
return JSONResponse({"error": cls.__name__}, status_code=cls.status_code)
class Unauthorized(APIError): class Unauthorized(APIError):
status_code = 401 status_code = 401

View File

@@ -0,0 +1,49 @@
from keycloak import KeycloakAuthenticationError
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from cpl.api.api_logger import APILogger
from cpl.api.error import Unauthorized
from cpl.api.router import Router
from cpl.auth.keycloak import KeycloakClient
from cpl.dependency import ServiceProviderABC
_logger = APILogger(__name__)
class AuthenticationMiddleware(BaseHTTPMiddleware):
@classmethod
async def _verify_login(cls, token: str) -> bool:
keycloak = ServiceProviderABC.get_global_service(KeycloakClient)
try:
user_info = keycloak.userinfo(token)
if not user_info:
return False
except KeycloakAuthenticationError:
return False
return True
async def dispatch(self, request: Request, call_next):
url = request.url.path
if url not in Router.get_auth_required_routes():
_logger.trace(f"No authentication required for {url}")
return await call_next(request)
if not request.headers.get("Authorization"):
_logger.debug(f"Unauthorized access to {url}, missing Authorization header")
return Unauthorized(f"Missing header Authorization").response()
auth_header = request.headers.get("Authorization", None)
if not auth_header or not auth_header.startswith("Bearer "):
return Unauthorized("Invalid Authorization header").response()
if not await self._verify_login(auth_header.split("Bearer ")[1]):
_logger.debug(f"Unauthorized access to {url}, invalid token")
return Unauthorized("Invalid token").response()
# check user exists in db, if not create
# unauthorized if user is deleted
return await call_next(request)

View File

@@ -3,11 +3,35 @@ from starlette.routing import Route
class Router: class Router:
_registered_routes: list[Route] = [] _registered_routes: list[Route] = []
_auth_required: list[str] = []
@classmethod @classmethod
def get_routes(cls) -> list[Route]: def get_routes(cls) -> list[Route]:
return cls._registered_routes return cls._registered_routes
@classmethod
def get_auth_required_routes(cls) -> list[str]:
return cls._auth_required
@classmethod
def authenticate(cls):
"""
Decorator to mark a route as requiring authentication.
Usage:
@Route.authenticate()
@Route.get("/example")
async def example_endpoint(request: TRequest):
...
"""
def inner(fn):
route_path = getattr(fn, "_route_path", None)
if route_path and route_path not in cls._auth_required:
cls._auth_required.append(route_path)
return fn
return inner
@classmethod @classmethod
def route(cls, path=None, **kwargs): def route(cls, path=None, **kwargs):
def inner(fn): def inner(fn):
@@ -57,4 +81,4 @@ class Router:
return fn return fn
return inner return inner

View File

@@ -10,4 +10,4 @@ HTTPMethods = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
PartialMiddleware = Union[ PartialMiddleware = Union[
Middleware, Middleware,
Callable[[ASGIApp], ASGIApp], Callable[[ASGIApp], ASGIApp],
] ]

View File

@@ -13,6 +13,7 @@ from starlette.types import ExceptionHandler
from cpl.api.api_logger import APILogger from cpl.api.api_logger import APILogger
from cpl.api.api_settings import ApiSettings from cpl.api.api_settings import ApiSettings
from cpl.api.error import APIError from cpl.api.error import APIError
from cpl.api.middleware.authentication import AuthenticationMiddleware
from cpl.api.middleware.logging import LoggingMiddleware from cpl.api.middleware.logging import LoggingMiddleware
from cpl.api.middleware.request import RequestMiddleware from cpl.api.middleware.request import RequestMiddleware
from cpl.api.router import Router from cpl.api.router import Router
@@ -24,7 +25,6 @@ from cpl.dependency.service_provider_abc import ServiceProviderABC
_logger = APILogger("API") _logger = APILogger("API")
class WebApp(ApplicationABC): class WebApp(ApplicationABC):
def __init__(self, services: ServiceProviderABC): def __init__(self, services: ServiceProviderABC):
super().__init__(services) super().__init__(services)
@@ -37,18 +37,22 @@ class WebApp(ApplicationABC):
Middleware(RequestMiddleware), Middleware(RequestMiddleware),
Middleware(LoggingMiddleware), Middleware(LoggingMiddleware),
] ]
self._exception_handlers: Mapping[Any, ExceptionHandler] = {Exception: self.handle_exception} self._exception_handlers: Mapping[Any, ExceptionHandler] = {
Exception: self._handle_exception,
APIError: self._handle_exception,
}
@staticmethod @staticmethod
async def handle_exception(request: Request, exc: Exception): async def _handle_exception(request: Request, exc: Exception):
if isinstance(exc, APIError):
_logger.error(exc)
return JSONResponse({"error": str(exc)}, status_code=exc.status_code)
if hasattr(request.state, "request_id"): if hasattr(request.state, "request_id"):
_logger.error(f"Request {request.state.request_id}", exc) _logger.error(f"Request {request.state.request_id}", exc)
else: else:
_logger.error("Request unknown", exc) _logger.error("Request unknown", exc)
if isinstance(exc, APIError):
return JSONResponse({"error": str(exc)}, status_code=exc.status_code)
return JSONResponse({"error": str(exc)}, status_code=500) return JSONResponse({"error": str(exc)}, status_code=500)
def _get_allowed_origins(self): def _get_allowed_origins(self):
@@ -96,7 +100,15 @@ class WebApp(ApplicationABC):
self._check_for_app() self._check_for_app()
assert path is not None, "path must not be None" assert path is not None, "path must not be None"
assert fn is not None, "fn must not be None" assert fn is not None, "fn must not be None"
assert method in ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"], "method must be a valid HTTP method" assert method in [
"GET",
"POST",
"PUT",
"DELETE",
"PATCH",
"OPTIONS",
"HEAD",
], "method must be a valid HTTP method"
self._routes.append(Route(path, fn, methods=[method], **kwargs)) self._routes.append(Route(path, fn, methods=[method], **kwargs))
return self return self
@@ -105,15 +117,20 @@ class WebApp(ApplicationABC):
if isinstance(middleware, Middleware): if isinstance(middleware, Middleware):
self._middleware.append(middleware) self._middleware.append(middleware)
elif callable(middleware): elif callable(middleware):
self._middleware.append(Middleware(middleware)) self._middleware.append(Middleware(middleware))
else: else:
raise ValueError("middleware must be of type starlette.middleware.Middleware or a callable") raise ValueError("middleware must be of type starlette.middleware.Middleware or a callable")
return self return self
def with_authentication(self):
self.with_middleware(AuthenticationMiddleware)
return self
def with_authorization(self):
pass
def main(self): def main(self):
_logger.debug(f"Preparing API") _logger.debug(f"Preparing API")
if self._app is None: if self._app is None:

View File

@@ -3,16 +3,16 @@ requires = ["setuptools>=70.1.0", "wheel>=0.43.0"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "cpl-application" name = "cpl-api"
version = "2024.7.0" version = "2024.7.0"
description = "CPL application" description = "CPL api"
readme ="CPL application package" readme ="CPL api package"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { text = "MIT" } license = { text = "MIT" }
authors = [ authors = [
{ name = "Sven Heidemann", email = "sven.heidemann@sh-edraft.de" } { name = "Sven Heidemann", email = "sven.heidemann@sh-edraft.de" }
] ]
keywords = ["cpl", "application", "backend", "shared", "library"] keywords = ["cpl", "api", "backend", "shared", "library"]
dynamic = ["dependencies", "optional-dependencies"] dynamic = ["dependencies", "optional-dependencies"]

View File

@@ -3,4 +3,5 @@ cpl-application
cpl-core cpl-core
cpl-dependency cpl-dependency
starlette==0.48.0 starlette==0.48.0
python-multipart==0.0.20 python-multipart==0.0.20
uvicorn==0.35.0

View File

@@ -1,4 +1,4 @@
cpl-core cpl-core
cpl-dependency cpl-dependency
cpl-database cpl-database
python-keycloak-5.8.1 python-keycloak==5.8.1

View File

@@ -2,5 +2,4 @@ art==6.5
colorama==0.4.6 colorama==0.4.6
tabulate==0.9.0 tabulate==0.9.0
termcolor==3.1.0 termcolor==3.1.0
mysql-connector-python==9.4.0
pynput==1.8.1 pynput==1.8.1

View File

@@ -77,7 +77,7 @@ class ServiceProvider(ServiceProviderABC):
return implementations return implementations
def _build_by_signature(self, sig: Signature, origin_service_type: type=None) -> list[R]: def _build_by_signature(self, sig: Signature, origin_service_type: type = None) -> list[R]:
params = [] params = []
for param in sig.parameters.items(): for param in sig.parameters.items():
parameter = param[1] parameter = param[1]

View File

@@ -36,7 +36,7 @@ class ServiceProviderABC(ABC):
return cls._provider.get_services(instance_type, *args, **kwargs) return cls._provider.get_services(instance_type, *args, **kwargs)
@abstractmethod @abstractmethod
def _build_by_signature(self, sig: Signature, origin_service_type: type=None) -> list[R]: ... def _build_by_signature(self, sig: Signature, origin_service_type: type = None) -> list[R]: ...
@abstractmethod @abstractmethod
def _build_service(self, service_type: type, *args, **kwargs) -> object: def _build_service(self, service_type: type, *args, **kwargs) -> object:

View File

@@ -2,7 +2,7 @@ from starlette.responses import JSONResponse
from cpl.api.web_app import WebApp from cpl.api.web_app import WebApp
from cpl.application import ApplicationBuilder from cpl.application import ApplicationBuilder
from custom.api.src.service import PingService from service import PingService
def main(): def main():
@@ -15,6 +15,7 @@ def main():
app.with_route(path="/route1", fn=lambda r: JSONResponse("route1"), method="GET") app.with_route(path="/route1", fn=lambda r: JSONResponse("route1"), method="GET")
app.with_routes_directory("routes") app.with_routes_directory("routes")
app.with_logging() app.with_logging()
app.with_authentication()
app.run() app.run()

View File

@@ -4,9 +4,10 @@ from starlette.responses import JSONResponse
from cpl.api.router import Router from cpl.api.router import Router
from cpl.core.log import Logger from cpl.core.log import Logger
from custom.api.src.service import PingService from service import PingService
@Router.authenticate()
@Router.get(f"/ping") @Router.get(f"/ping")
async def ping(r: Request, ping: PingService, logger: Logger): async def ping(r: Request, ping: PingService, logger: Logger):
logger.info(f"Ping: {ping}") logger.info(f"Ping: {ping}")