api: implement automatic ssh key fetching from keycloak
Some checks failed
Build Container / Build Container (push) Has been cancelled
Some checks failed
Build Container / Build Container (push) Has been cancelled
This commit is contained in:
parent
279a45a9e8
commit
1932ead08f
6 changed files with 113 additions and 2 deletions
1
.dev.env
1
.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
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,4 +3,5 @@
|
|||
.env
|
||||
|
||||
**/__pycache__
|
||||
**/authorized_keys
|
||||
api/dist
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
83
api/src/dooris_api/keycloak.py
Normal file
83
api/src/dooris_api/keycloak.py
Normal file
|
|
@ -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)
|
||||
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue