api: implement automatic ssh key fetching from keycloak
All checks were successful
Build Container / Build Container (push) Successful in 1m26s
All checks were successful
Build Container / Build Container (push) Successful in 1m26s
This commit is contained in:
parent
279a45a9e8
commit
914a4497c7
6 changed files with 111 additions and 0 deletions
1
.dev.env
1
.dev.env
|
|
@ -3,3 +3,4 @@ DOORIS_OPENID_CLIENT_ID=dooris
|
||||||
DOORIS_OPENID_CLIENT_SECRET=dp9HhnvUhAtKm3pRnxfGA7q8Nwrd1td8
|
DOORIS_OPENID_CLIENT_SECRET=dp9HhnvUhAtKm3pRnxfGA7q8Nwrd1td8
|
||||||
DOORIS_BASE_URL=http://localhost:8000
|
DOORIS_BASE_URL=http://localhost:8000
|
||||||
DOORIS_CCUJACK_USER=dooris
|
DOORIS_CCUJACK_USER=dooris
|
||||||
|
DOORIS_AUTHORIZED_KEYS_FILE=./authorized_keys
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,4 +3,5 @@
|
||||||
.env
|
.env
|
||||||
|
|
||||||
**/__pycache__
|
**/__pycache__
|
||||||
|
**/authorized_keys
|
||||||
api/dist
|
api/dist
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from argparse import ArgumentParser, Namespace
|
from argparse import ArgumentParser, Namespace
|
||||||
|
|
||||||
|
|
@ -80,6 +81,18 @@ def main():
|
||||||
action="append",
|
action="append",
|
||||||
default=[i for i in os.environ.get("DOORIS_STATIC_API_TOKENS", "").split(",") if bool(i)],
|
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()
|
args = argp.parse_args()
|
||||||
|
|
||||||
# setup logging
|
# setup logging
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from aiohttp import BasicAuth
|
||||||
|
|
||||||
from dooris_api import deps, models, exceptions, app_config
|
from dooris_api import deps, models, exceptions, app_config
|
||||||
from dooris_api.ccujack import CCUJackClient
|
from dooris_api.ccujack import CCUJackClient
|
||||||
|
from dooris_api.keycloak import KeycloakClient
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -37,9 +38,18 @@ async def lifespan(app: FastAPI):
|
||||||
)
|
)
|
||||||
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
|
yield
|
||||||
|
|
||||||
await app.extra["ccujack"].close_connections()
|
await app.extra["ccujack"].close_connections()
|
||||||
|
await app.extra["keycloak"].stop()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,9 @@ class CCUJackClient:
|
||||||
|
|
||||||
await self.find_locks()
|
await self.find_locks()
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("CCUJack task cron stopped")
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error in CCUJack cron task: {e}")
|
logger.exception(f"Error in CCUJack cron task: {e}")
|
||||||
finally:
|
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