diff --git a/.dev.env b/.dev.env index eb77b35..59f4383 100644 --- a/.dev.env +++ b/.dev.env @@ -3,3 +3,4 @@ DOORIS_OPENID_CLIENT_ID=dooris DOORIS_OPENID_CLIENT_SECRET=dp9HhnvUhAtKm3pRnxfGA7q8Nwrd1td8 DOORIS_BASE_URL=http://localhost:8000 DOORIS_CCUJACK_USER=dooris +DOORIS_AUTHORIZED_KEYS_FILE=./authorized_keys diff --git a/.gitignore b/.gitignore index 4ad5c33..7b869c5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ .env **/__pycache__ +**/authorized_keys api/dist diff --git a/api/src/dooris_api/__init__.py b/api/src/dooris_api/__init__.py index 203d379..10d1ff5 100644 --- a/api/src/dooris_api/__init__.py +++ b/api/src/dooris_api/__init__.py @@ -1,5 +1,6 @@ import os import logging +from pathlib import Path from contextvars import ContextVar from argparse import ArgumentParser, Namespace @@ -80,6 +81,18 @@ def main(): action="append", default=[i for i in os.environ.get("DOORIS_STATIC_API_TOKENS", "").split(",") if bool(i)], ) + argp.add_argument( + "--kc-ssh-attr-group", + required=False, + default=os.environ.get("DOORIS_KC_SSH_ATTR_GROUP", "dooris-ssh-keys"), + ) + argp.add_argument( + "--authorized-keys-file", + required="DOORIS_AUTHORIZED_KEYS_FILE" not in os.environ, + default=os.environ.get("DOORIS_AUTHORIZED_KEYS_FILE", None), + type=Path, + help="A file to which ssh authorized keys fetched from keycloak will be written", + ) args = argp.parse_args() # setup logging diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py index 0a33704..d485a3b 100644 --- a/api/src/dooris_api/app.py +++ b/api/src/dooris_api/app.py @@ -13,6 +13,7 @@ from aiohttp import BasicAuth from dooris_api import deps, models, exceptions, app_config from dooris_api.ccujack import CCUJackClient +from dooris_api.keycloak import KeycloakClient logger = logging.getLogger(__name__) @@ -35,11 +36,20 @@ async def lifespan(app: FastAPI): auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password), mqtt_conn=app_cfg.ccujack_mqtt, ) - await app.extra["ccujack"].start() + # await app.extra["ccujack"].start() + + app.extra["keycloak"] = KeycloakClient( + base_uri=app_cfg.openid_issuer, + oidc_client=app.extra["oidc_client"], + keycloak_attribute_group=app_cfg.kc_ssh_attr_group, + authorized_keys_file=app_cfg.authorized_keys_file, + ) + app.extra["keycloak"].start() yield - await app.extra["ccujack"].close_connections() + # await app.extra["ccujack"].close_connections() + await app.extra["keycloak"].stop() app = FastAPI( diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py index de08bbf..8cf767f 100644 --- a/api/src/dooris_api/ccujack.py +++ b/api/src/dooris_api/ccujack.py @@ -166,6 +166,9 @@ class CCUJackClient: await self.find_locks() + except asyncio.CancelledError: + logger.info("CCUJack task cron stopped") + raise except Exception as e: logger.exception(f"Error in CCUJack cron task: {e}") finally: diff --git a/api/src/dooris_api/keycloak.py b/api/src/dooris_api/keycloak.py new file mode 100644 index 0000000..ea614d1 --- /dev/null +++ b/api/src/dooris_api/keycloak.py @@ -0,0 +1,83 @@ +from typing import List +from aiohttp import ClientSession, ClientRequest, ClientHandlerType, ClientResponse +import asyncio +import logging +from pathlib import Path +from simple_openid_connect.client import OpenidClient +from simple_openid_connect.data import TokenErrorResponse + + +logger = logging.getLogger(__name__) + + +class ClientCredentialsAuth: + def __init__(self, oidc_client: OpenidClient): + self.oidc_client = oidc_client + + async def __call__(self, req: ClientRequest, handler: ClientHandlerType) -> ClientResponse: + resp = self.oidc_client.client_credentials_grant.authenticate() + if isinstance(resp, TokenErrorResponse): + raise Exception(f"Could not authenticate against keycloak with client-credentials-grant: {resp.error} ({resp.error_description})") + + req.headers["Authorization"] = f"Bearer {resp.access_token}" + return await handler(req) + + +class KeycloakClient: + keycloak_attribute_group: str + authorized_keys_file: Path + ssh_keys: List[str] + data_updated: asyncio.Event + task_cron = asyncio.Task + + def __init__(self, base_uri: str, oidc_client: OpenidClient, keycloak_attribute_group: str, authorized_keys_file: Path): + self.base_uri: str + self.keycloak_attribute_group = keycloak_attribute_group + self.ssh_keys = [] + self.data_updated = asyncio.Event() + self.authorized_keys_file = authorized_keys_file + self.http = ClientSession( + base_url=base_uri, + middlewares=[ClientCredentialsAuth(oidc_client)], + raise_for_status=True, + ) + + def start(self): + self.task_cron = asyncio.get_running_loop().create_task(self.cron(), name="keycloak-cron") + + async def stop(self): + await self.http.close() + self.task_cron.cancel() + self.task_cron = None + + async def cron(self): + while True: + try: + + logger.info("Running keycloak cron") + await self.fetch_ssh_keys() + await self.write_authorized_keys() + + except asyncio.CancelledError: + logger.info("keycloak cron stopped") + raise + except Exception as e: + logger.exception(f"Error in Keycloak cron task: {e}") + finally: + await asyncio.sleep(15 * 60) # 15 minutes + + async def fetch_ssh_keys(self): + logger.info("Fetching ssh keys from keycloak") + async with self.http.get(f"./attribute-endpoints-provider/export/{self.keycloak_attribute_group}") as resp: + keys = await resp.json() + assert isinstance(keys, list) + self.ssh_keys = keys + self.data_updated.set() + self.data_updated.clear() + + async def write_authorized_keys(self): + logger.info(f"Writing authorized keys to {self.authorized_keys_file.absolute()}") + with self.authorized_keys_file.open(mode="wt", encoding="UTF-8") as f: + f.writelines(self.ssh_keys) + +