Compare commits
2 commits
main
...
cat-langua
| Author | SHA1 | Date | |
|---|---|---|---|
|
d46d866705 |
|||
|
401bea8927 |
17 changed files with 175 additions and 675 deletions
1
.dev.env
1
.dev.env
|
|
@ -3,4 +3,3 @@ 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,5 +3,4 @@
|
|||
.env
|
||||
|
||||
**/__pycache__
|
||||
**/authorized_keys
|
||||
api/dist
|
||||
|
|
|
|||
|
|
@ -1,16 +1,3 @@
|
|||
FROM docker.io/alpine:3.22 AS build-frontend
|
||||
ENV PNPM_HOME=/usr/local/share/dooris/pnpm/
|
||||
WORKDIR /usr/local/src/dooris/
|
||||
RUN apk add --no-cache pnpm
|
||||
|
||||
ADD --link app/package.json app/pnpm-lock.yaml app/
|
||||
RUN pnpm --dir=app/ install --frozen-lockfile --package-import-method=copy
|
||||
|
||||
ADD --link . /usr/local/src/dooris/
|
||||
RUN pnpm --dir=app/ run build
|
||||
|
||||
|
||||
|
||||
FROM docker.io/alpine:3.22 AS base
|
||||
|
||||
ARG APP_UID=10000
|
||||
|
|
@ -22,11 +9,12 @@ ENV UV_LINK_MODE=copy
|
|||
ENV UV_CACHE_DIR=/var/cache/dooris/uv/
|
||||
ENV UV_NO_MANAGED_PYTHON=true
|
||||
ENV VIRTUAL_ENV=/usr/local/share/dooris/venv/
|
||||
ENV PATH=$VIRTUAL_ENV/bin:$PATH
|
||||
ENV PNPM_HOME=/usr/local/share/dooris/pnpm/
|
||||
ENV PATH=$PNPM_HOME:$VIRTUAL_ENV/bin:$PATH
|
||||
ENV DOORIS_SERVE_STATIC=/var/www/dooris/static/
|
||||
WORKDIR /usr/local/src/dooris/
|
||||
|
||||
RUN apk add --no-cache uv python3
|
||||
RUN apk add --no-cache uv python3 pnpm
|
||||
RUN addgroup -g $APP_GID dooris &&\
|
||||
adduser -h /usr/local/src/dooris -u $APP_UID -G dooris -D dooris &&\
|
||||
mkdir -p /var/www/dooris/ /usr/local/share/dooris/ /usr/local/src/dooris/ /var/cache/dooris/ &&\
|
||||
|
|
@ -37,14 +25,16 @@ RUN addgroup -g $APP_GID dooris &&\
|
|||
FROM base AS deps
|
||||
USER dooris
|
||||
ADD --link --chown=dooris:dooris api/pyproject.toml api/uv.lock api/
|
||||
ADD --link --chown=dooris:dooris app/package.json app/pnpm-lock.yaml app/
|
||||
RUN uv venv $VIRTUAL_ENV &&\
|
||||
uv sync --active --frozen --no-install-project --no-editable
|
||||
RUN pnpm --dir=app/ install --frozen-lockfile --package-import-method=copy
|
||||
|
||||
|
||||
|
||||
FROM deps AS final
|
||||
ADD --chown=dooris:dooris --link . /usr/local/src/dooris/
|
||||
COPY --chown=dooris:dooris --from=build-frontend --link /usr/local/src/dooris/app/dist/ $DOORIS_SERVE_STATIC
|
||||
RUN pnpm --dir=app/ run build --outDir=$DOORIS_SERVE_STATIC
|
||||
RUN --mount=type=cache,uid=$APP_UID,gid=$APP_GID,target=$UV_CACHE_DIR \
|
||||
uv sync --active --frozen
|
||||
|
||||
|
|
|
|||
|
|
@ -26,10 +26,8 @@ THe following table lists all available configuration parameters:
|
|||
| `--base-url` | `DOORIS_BASE_URL` | Yes | *None* |
|
||||
| `--serve-static` | `DOORIS_SERVE_STATIC` | No | *None* |
|
||||
| `--ccujack-url` | `DOORIS_CCUJACK_URL` | No | `https://hmdooris-ccu.ccchh.net:2122` |
|
||||
| `--ccujack-mqtt` | `DOORIS_CCUJACK_MQTT` | No | `hmdooris-ccu.ccchh.net:1883` |
|
||||
| `--ccujack-user` | `DOORIS_CCUJACK_USER` | Yes | *None* |
|
||||
| `--ccujack-password` | `DOORIS_CCUJACK_PASSWORD` | Yes | *None* |
|
||||
| `--static-api-tokens` | `DOORIS_STATIC_API_TOKENS` | No | *None* |
|
||||
|
||||
## API Development
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ requires-python = ">=3.12"
|
|||
dependencies = [
|
||||
"aiohttp>=3.13.5",
|
||||
"fastapi>=0.136.1",
|
||||
"paho-mqtt>=2.1.0",
|
||||
"simple-openid-connect>=2.4.0",
|
||||
"uvicorn>=0.46.0",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from contextvars import ContextVar
|
||||
from argparse import ArgumentParser, Namespace
|
||||
|
||||
|
|
@ -51,17 +49,9 @@ def main():
|
|||
argp.add_argument(
|
||||
"--ccujack-url",
|
||||
required=False,
|
||||
default=os.environ.get(
|
||||
"DOORIS_CCUJACK_URL", "https://hmdooris-ccu.ccchh.net:2122"
|
||||
),
|
||||
default=os.environ.get("DOORIS_CCUJACK_URL", "https://hmdooris-ccu.ccchh.net:2122"),
|
||||
help="The URL under which a CCUJACK instance is hosted that actually operates the locks",
|
||||
)
|
||||
argp.add_argument(
|
||||
"--ccujack-mqtt",
|
||||
required=False,
|
||||
default=os.environ.get("DOORIS_CCUJACK_MQTT", "hmdooris-ccu.ccchh.net:1883"),
|
||||
help="The $HOSTNAME:$PORT of the CCUJack embedded MQTT server",
|
||||
)
|
||||
argp.add_argument(
|
||||
"--ccujack-user",
|
||||
required="DOORIS_CCUJACK_USER" not in os.environ,
|
||||
|
|
@ -72,47 +62,18 @@ def main():
|
|||
"--ccujack-password",
|
||||
required="DOORIS_CCUJACK_PASSWORD" not in os.environ,
|
||||
default=os.environ.get("DOORIS_CCUJACK_PASSWORD", None),
|
||||
help="The password used to authenticate against the CCUJACK",
|
||||
)
|
||||
argp.add_argument(
|
||||
"--static-api-tokens",
|
||||
required=False,
|
||||
nargs=1,
|
||||
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",
|
||||
help="The password used to authenticate against the CCUJACK"
|
||||
)
|
||||
args = argp.parse_args()
|
||||
|
||||
# setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG, format="[%(levelname)s] %(filename)s: %(message)s"
|
||||
)
|
||||
|
||||
# setup app
|
||||
app_config.set(args)
|
||||
import uvicorn
|
||||
from dooris_api.app import app
|
||||
|
||||
if args.serve_static:
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
app.mount(
|
||||
"/", StaticFiles(directory=args.serve_static, html=True), name="static"
|
||||
)
|
||||
|
||||
# start webserver
|
||||
app.mount("/", StaticFiles(directory=args.serve_static, html=True), name="static")
|
||||
|
||||
config = uvicorn.Config(app, port=8000, log_level="debug")
|
||||
server = uvicorn.Server(config)
|
||||
server.run()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
from typing import Optional, List, AsyncIterable
|
||||
from typing import Optional, List
|
||||
import logging
|
||||
import secrets
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, UTC
|
||||
from fastapi import FastAPI, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.sse import EventSourceResponse
|
||||
from contextlib import asynccontextmanager
|
||||
from simple_openid_connect.client import OpenidClient
|
||||
from simple_openid_connect.data import TokenSuccessResponse, RpInitiatedLogoutRequest
|
||||
|
|
@ -13,7 +13,6 @@ 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__)
|
||||
|
|
@ -23,6 +22,12 @@ logger = logging.getLogger(__name__)
|
|||
async def lifespan(app: FastAPI):
|
||||
app_cfg = app_config.get()
|
||||
|
||||
root_logger = logging.getLogger("")
|
||||
root_logger.setLevel(logging.INFO)
|
||||
root_logger.addHandler(logging.StreamHandler(sys.stderr))
|
||||
app_logger = logging.getLogger("dooris_api")
|
||||
app_logger.setLevel(logging.DEBUG)
|
||||
|
||||
app.extra["oidc_client"] = OpenidClient.from_issuer_url(
|
||||
url=app_cfg.openid_issuer,
|
||||
authentication_redirect_uri=f"{app_cfg.base_url}/auth/login-callback",
|
||||
|
|
@ -33,24 +38,12 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
app.extra["ccujack"] = CCUJackClient(
|
||||
base_uri=app_cfg.ccujack_url,
|
||||
auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password),
|
||||
mqtt_conn=app_cfg.ccujack_mqtt,
|
||||
auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password)
|
||||
)
|
||||
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()
|
||||
await app.extra["ccujack"].find_locks()
|
||||
|
||||
yield
|
||||
|
||||
await app.extra["ccujack"].close_connections()
|
||||
await app.extra["keycloak"].stop()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Dooris",
|
||||
|
|
@ -68,9 +61,19 @@ app.add_exception_handler(
|
|||
"/api/user-info/",
|
||||
name="get-user-info",
|
||||
tags=["auth"],
|
||||
responses={status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail}},
|
||||
)
|
||||
async def get_user_info(req: Request, current_user: deps.ApiUser) -> models.ApiUser:
|
||||
return current_user
|
||||
async def get_user_info(
|
||||
req: Request, current_user: deps.CurrentUser
|
||||
) -> models.UserStatus:
|
||||
return models.UserStatus(
|
||||
is_authorized=current_user.may_operate_locks,
|
||||
guaranteed_session_until=datetime.fromtimestamp(
|
||||
current_user.id_token.exp, UTC
|
||||
),
|
||||
username=current_user.id_token.preferred_username,
|
||||
ccchh_roles=current_user.ccchh_roles,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/auth/login", tags=["auth"], response_class=RedirectResponse, status_code=302)
|
||||
|
|
@ -116,9 +119,7 @@ async def login_init(
|
|||
response_class=RedirectResponse,
|
||||
status_code=302,
|
||||
)
|
||||
async def login_callback(
|
||||
req: Request, resp: Response, oidc_client: deps.OpenidClient
|
||||
) -> str:
|
||||
async def login_callback(req: Request, resp: Response, oidc_client: deps.OpenidClient) -> str:
|
||||
# check that the user is currently in an authenticating state
|
||||
# these cookies are set by the login_init() view
|
||||
if (
|
||||
|
|
@ -145,7 +146,7 @@ async def login_callback(
|
|||
auth_start_time = datetime.fromtimestamp(
|
||||
float(req.cookies["auth_start_time"]), UTC
|
||||
)
|
||||
deps.persist_oidc_auth_state(
|
||||
deps.persist_auth_state(
|
||||
oidc_client, resp, auth_result, auth_start_time, req.cookies["auth_nonce"]
|
||||
)
|
||||
logger.debug("successfully authenticated user")
|
||||
|
|
@ -167,9 +168,9 @@ async def login_callback(
|
|||
"/auth/logout", tags=["auth"], response_class=RedirectResponse, status_code=302
|
||||
)
|
||||
async def logout(
|
||||
resp: Response, oidc_client: deps.OpenidClient, current_user: deps.AuthenticatedUser
|
||||
resp: Response, oidc_client: deps.OpenidClient, current_user: deps.CurrentUser
|
||||
) -> str:
|
||||
deps.clear_oidc_auth_state(resp)
|
||||
deps.clear_auth_state(resp)
|
||||
return oidc_client.initiate_logout(
|
||||
RpInitiatedLogoutRequest(
|
||||
id_token_hint=current_user.raw_id_token,
|
||||
|
|
@ -236,19 +237,6 @@ async def list_locks(ccujack: deps.CCUJackClient) -> List[models.Lock]:
|
|||
return result
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/locks/stream",
|
||||
tags=["locks"],
|
||||
responses={status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail}},
|
||||
response_class=EventSourceResponse,
|
||||
)
|
||||
async def watch_locks(ccujack: deps.CCUJackClient) -> AsyncIterable[List[models.Lock]]:
|
||||
while True:
|
||||
yield await list_locks(ccujack)
|
||||
await ccujack.data_updated.wait()
|
||||
await asyncio.sleep(0.1) # debounce multiple mqtt parameter updates
|
||||
|
||||
|
||||
@app.patch(
|
||||
"/api/locks/{lock_id}",
|
||||
tags=["locks"],
|
||||
|
|
@ -263,7 +251,7 @@ async def operate_lock(
|
|||
lock_id: str,
|
||||
requested_op: models.LockOperation,
|
||||
ccujack: deps.CCUJackClient,
|
||||
current_user: deps.AuthenticatedUser,
|
||||
current_user: deps.CurrentUser,
|
||||
) -> None:
|
||||
if not current_user.may_operate_locks:
|
||||
raise exceptions.HttpProblemException.forbidden_to_operate(req.url)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
from typing import List, Tuple, Optional, Any, Dict
|
||||
from typing import List, Tuple, Optional, Any
|
||||
from aiohttp import ClientSession, BasicAuth, TCPConnector
|
||||
import logging
|
||||
import asyncio
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from dooris_api.mqtt_client import AsyncMqttClient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -70,47 +68,21 @@ LockData = List[Tuple[CCUDeviceInfo, List[Tuple[CCUChannelInfo, List[CCUParamInf
|
|||
class CCUJackClient:
|
||||
base_uri: str
|
||||
locks: LockData
|
||||
param_values: Dict[str, Any]
|
||||
task_process_messages: asyncio.Task
|
||||
task_find_locks: asyncio.Task
|
||||
data_updated: asyncio.Event
|
||||
|
||||
def __init__(self, base_uri: str, auth: BasicAuth, mqtt_conn: str):
|
||||
def __init__(self, base_uri: str, auth: BasicAuth):
|
||||
self.http = ClientSession(
|
||||
base_url=base_uri,
|
||||
auth=auth,
|
||||
raise_for_status=True,
|
||||
connector=TCPConnector(ssl=False),
|
||||
)
|
||||
self.mqtt = AsyncMqttClient(mqtt_conn, auth.login, auth.password)
|
||||
self.locks = None
|
||||
self.param_values = dict()
|
||||
self.task_process_messages = None
|
||||
self.task_find_locks = None
|
||||
self.data_updated = asyncio.Event()
|
||||
|
||||
async def start(self):
|
||||
self.task_process_messages = asyncio.get_running_loop().create_task(
|
||||
self.process_mqt_messages(), name="process-mqtt-messages"
|
||||
)
|
||||
self.task_cron= asyncio.get_running_loop().create_task(
|
||||
self.cron(), name="ccujack-cron"
|
||||
)
|
||||
|
||||
async def close_connections(self):
|
||||
await asyncio.gather(self.mqtt.disconnect(), self.http.close())
|
||||
self.task_process_messages.cancel()
|
||||
self.task_process_messages = None
|
||||
self.task_cron.cancel()
|
||||
self.task_cron = None
|
||||
|
||||
async def find_locks(self):
|
||||
logger.debug("Inspecting lock devices present in CCUJack")
|
||||
|
||||
async with self.http.get("/device") as resp:
|
||||
devices = CCUDeviceList.model_validate(await resp.json())
|
||||
|
||||
# inspect CCUJACK for locks
|
||||
device_infos = await asyncio.gather(
|
||||
*[
|
||||
self._inspect_ccu_device(i)
|
||||
|
|
@ -119,66 +91,9 @@ class CCUJackClient:
|
|||
]
|
||||
)
|
||||
|
||||
# save the result
|
||||
new_locks = [i for i in device_infos if i[0].type == DEVICE_TYPE_LOCK]
|
||||
if new_locks != self.locks:
|
||||
logger.info("Found new locks, updating state")
|
||||
self.locks = new_locks
|
||||
self.data_updated.set()
|
||||
self.data_updated.clear()
|
||||
|
||||
# update active mqtt subscriptions based on newly discovered devices
|
||||
mqtt_topics = set()
|
||||
for i_lock, lock_channels in self.locks:
|
||||
for i_channel, channel_params in lock_channels:
|
||||
for i_param in channel_params:
|
||||
mqtt_topics.add(
|
||||
f"device/status/{i_lock.address}/{i_channel.index}/{i_param.id}"
|
||||
)
|
||||
await self.mqtt.update_subscriptions(mqtt_topics)
|
||||
|
||||
async def process_mqt_messages(self):
|
||||
while True:
|
||||
try:
|
||||
msg = await self.mqtt.messages.get()
|
||||
|
||||
param_name = msg.topic.removeprefix("device/status/")
|
||||
param_value = CCUValue.model_validate_json(msg.payload)
|
||||
logger.debug(
|
||||
f"Got new value from MQTT for parameter {param_name}: {param_value}"
|
||||
)
|
||||
self.param_values[param_name] = param_value
|
||||
self.data_updated.set()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"could not process incoming mqtt message: {e}")
|
||||
finally:
|
||||
self.data_updated.clear()
|
||||
|
||||
async def cron(self):
|
||||
while True:
|
||||
try:
|
||||
|
||||
logger.info("Running CCUJack cron")
|
||||
if not self.mqtt.is_connected:
|
||||
logger.warning("MQTT client was discovered to be disconnected; reconnecting now")
|
||||
await self.mqtt.connect()
|
||||
|
||||
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:
|
||||
await asyncio.sleep(15 * 60) # 15 minutes
|
||||
|
||||
|
||||
async def query_param_value(self, address: str) -> CCUValue:
|
||||
if address in self.param_values:
|
||||
return self.param_values[address]
|
||||
self.locks = [i for i in device_infos if i[0].type == DEVICE_TYPE_LOCK]
|
||||
|
||||
async def query_param_value(self, address: str):
|
||||
logger.debug("Querying parameter value from '%s'", address)
|
||||
async with self.http.get(f"/device/{address}/~pv") as resp:
|
||||
return CCUValue.model_validate(await resp.json())
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
from typing import Annotated, Optional
|
||||
import logging
|
||||
from datetime import datetime, UTC, timedelta
|
||||
from fastapi import Request, Depends, Response, Header
|
||||
from fastapi.security import APIKeyHeader
|
||||
from fastapi import Request, Depends, Response
|
||||
from simple_openid_connect.data import TokenSuccessResponse
|
||||
from simple_openid_connect.client import OpenidClient
|
||||
|
||||
from dooris_api import app_config
|
||||
from dooris_api import models, exceptions
|
||||
from dooris_api.ccujack import CCUJackClient
|
||||
|
||||
|
|
@ -14,9 +12,6 @@ from dooris_api.ccujack import CCUJackClient
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
api_key_security_scheme = APIKeyHeader(name="Authorization", scheme_name="Static-Token", auto_error=False)
|
||||
|
||||
|
||||
async def get_oidc_client(req: Request) -> OpenidClient:
|
||||
return req.app.extra["oidc_client"]
|
||||
|
||||
|
|
@ -24,23 +19,24 @@ async def get_oidc_client(req: Request) -> OpenidClient:
|
|||
OpenidClient = Annotated[OpenidClient, Depends(get_oidc_client)]
|
||||
|
||||
|
||||
async def get_logged_in_oidc_user(
|
||||
async def get_current_user(
|
||||
req: Request, resp: Response, oidc_client: OpenidClient
|
||||
) -> Optional[models.ApiUser]:
|
||||
) -> Optional[models.CurrentUser]:
|
||||
# easiest case: we still have an access token (which is the most fleeting component)
|
||||
# everything else should still be valid so we can just use it
|
||||
if all(i in req.cookies for i in ("access_token", "id_token")):
|
||||
if all(i in req.cookies for i in ("access_token", "id_token", "auth_nonce")):
|
||||
logger.debug(
|
||||
"user is fully authenticated, returning current user from existing id_token"
|
||||
)
|
||||
id_token = oidc_client.decode_id_token(
|
||||
req.cookies["id_token"],
|
||||
nonce=req.cookies.get("auth_nonce", None),
|
||||
req.cookies["id_token"], nonce=req.cookies["auth_nonce"]
|
||||
)
|
||||
return models.CurrentUser(
|
||||
id_token=id_token, raw_id_token=req.cookies["id_token"]
|
||||
)
|
||||
return models.ApiUser.from_id_token(id_token, req.cookies["id_token"])
|
||||
|
||||
# if we have a refresh token, try to get new tokens
|
||||
elif all(i in req.cookies for i in ("refresh_token",)):
|
||||
elif all(i in req.cookies for i in ("refresh_token", "auth_nonce")):
|
||||
logger.debug(
|
||||
"user has been previously authenticated, trying to recover with refresh_token"
|
||||
)
|
||||
|
|
@ -48,26 +44,24 @@ async def get_logged_in_oidc_user(
|
|||
token_resp = oidc_client.exchange_refresh_token(req.cookies["refresh_token"])
|
||||
if isinstance(token_resp, TokenSuccessResponse):
|
||||
logger.debug("successfully got new tokens from refresh token")
|
||||
persist_oidc_auth_state(
|
||||
oidc_client, resp, token_resp, auth_start_time, None
|
||||
)
|
||||
persist_auth_state(oidc_client, resp, token_resp, auth_start_time, req.cookies["auth_nonce"])
|
||||
|
||||
# return the newly gotten info
|
||||
id_token = oidc_client.decode_id_token(token_resp.id_token)
|
||||
return models.ApiUser.from_id_token(id_token, token_resp.id_token)
|
||||
else:
|
||||
logger.debug(
|
||||
"failed to exchange refresh token for new access token: %s", token_resp
|
||||
return models.CurrentUser(
|
||||
id_token=id_token, raw_id_token=token_resp.id_token
|
||||
)
|
||||
else:
|
||||
logger.debug("failed to exchange refresh token for new access token: %s", token_resp)
|
||||
|
||||
# otherwise we can't meaningfully recover any user information or the user is simply not authenticated
|
||||
else:
|
||||
logger.debug("no currently authenticated oidc user")
|
||||
logger.debug("no currently authenticated user")
|
||||
|
||||
return None
|
||||
raise exceptions.HttpProblemException.unauthorized(req.url)
|
||||
|
||||
|
||||
def persist_oidc_auth_state(
|
||||
def persist_auth_state(
|
||||
oidc_client: OpenidClient,
|
||||
resp: Response,
|
||||
tokens: TokenSuccessResponse,
|
||||
|
|
@ -123,7 +117,7 @@ def persist_oidc_auth_state(
|
|||
)
|
||||
|
||||
|
||||
def clear_oidc_auth_state(resp: Response):
|
||||
def clear_auth_state(resp: Response):
|
||||
resp.set_cookie("access_token", "", max_age=0)
|
||||
resp.set_cookie("refresh_token", "", max_age=0)
|
||||
resp.set_cookie("id_token", "", max_age=0)
|
||||
|
|
@ -133,65 +127,7 @@ def clear_oidc_auth_state(resp: Response):
|
|||
resp.set_cookie("auth_start_time", "", max_age=0)
|
||||
|
||||
|
||||
def get_logged_in_token_user(req: Request, token: Optional[str]):
|
||||
if not token or not token.startswith("Static-Token "):
|
||||
logger.debug("No valid API-Token was provided")
|
||||
return None
|
||||
|
||||
token = token.removeprefix("Static-Token ")
|
||||
valid_tokens = app_config.get().static_api_tokens
|
||||
|
||||
if any((i == token for i in valid_tokens)):
|
||||
logger.debug("Successfully authenticated a static API-Token")
|
||||
return models.ApiUser(
|
||||
is_anonymous=False,
|
||||
is_ccchh_user=False,
|
||||
is_token_user=True,
|
||||
may_operate_locks=True,
|
||||
username="static-token",
|
||||
guaranteed_session_until=None,
|
||||
raw_id_token=None,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_api_user(
|
||||
req: Request, resp: Response, oidc_client: OpenidClient, token: Annotated[Optional[str], Depends(api_key_security_scheme)] = None
|
||||
) -> models.ApiUser:
|
||||
oidc_user = await get_logged_in_oidc_user(req, resp, oidc_client)
|
||||
token_user = get_logged_in_token_user(req, token)
|
||||
|
||||
if oidc_user is not None:
|
||||
return oidc_user
|
||||
elif token_user is not None:
|
||||
return token_user
|
||||
else:
|
||||
return models.ApiUser(
|
||||
is_anonymous=True,
|
||||
is_ccchh_user=False,
|
||||
is_token_user=False,
|
||||
may_operate_locks=False,
|
||||
username="anonymous",
|
||||
guaranteed_session_until=None,
|
||||
raw_id_token=None,
|
||||
)
|
||||
|
||||
|
||||
ApiUser = Annotated[models.ApiUser, Depends(get_api_user)]
|
||||
|
||||
|
||||
async def get_authenticated_user(
|
||||
req: Request, resp: Response, oidc_client: OpenidClient
|
||||
) -> models.ApiUser:
|
||||
user = await get_api_user(req, resp, oidc_client)
|
||||
if user.is_anonymous:
|
||||
raise exceptions.HttpProblemException.unauthorized(req.url)
|
||||
else:
|
||||
return user
|
||||
|
||||
|
||||
AuthenticatedUser = Annotated[models.ApiUser, Depends(get_authenticated_user)]
|
||||
CurrentUser = Annotated[Optional[models.CurrentUser], Depends(get_current_user)]
|
||||
|
||||
|
||||
def get_ccujack(req: Request) -> CCUJackClient:
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
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)
|
||||
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from typing import Optional, Literal, Self
|
||||
from datetime import datetime, UTC
|
||||
from pydantic import BaseModel, HttpUrl, Field
|
||||
from typing import Optional, Literal, List
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
from enum import Enum
|
||||
from simple_openid_connect.data import IdToken
|
||||
|
||||
|
|
@ -27,27 +27,24 @@ class HttpProblemDetail(BaseModel):
|
|||
instance: Optional[HttpUrl]
|
||||
|
||||
|
||||
class ApiUser(BaseModel):
|
||||
is_anonymous: bool
|
||||
is_ccchh_user: bool
|
||||
is_token_user: bool
|
||||
may_operate_locks: bool
|
||||
class CurrentUser(BaseModel):
|
||||
id_token: IdToken
|
||||
raw_id_token: str
|
||||
|
||||
@property
|
||||
def ccchh_roles(self) -> List[str]:
|
||||
return getattr(self.id_token, "ccchh-roles", [])
|
||||
|
||||
@property
|
||||
def may_operate_locks(self) -> bool:
|
||||
return "intern@" in self.ccchh_roles
|
||||
|
||||
|
||||
class UserStatus(BaseModel):
|
||||
is_authorized: bool
|
||||
guaranteed_session_until: datetime
|
||||
username: str
|
||||
guaranteed_session_until: Optional[datetime]
|
||||
|
||||
raw_id_token: Optional[str] = Field(exclude=True)
|
||||
|
||||
@classmethod
|
||||
def from_id_token(cls, id_token: IdToken, raw_id_token: str) -> Self:
|
||||
return cls(
|
||||
is_anonymous=False,
|
||||
is_ccchh_user=True,
|
||||
is_token_user=False,
|
||||
may_operate_locks="intern@" in getattr(id_token, "ccchh-roles", []),
|
||||
username=id_token.preferred_username,
|
||||
guaranteed_session_until=datetime.fromtimestamp(id_token.exp, UTC),
|
||||
raw_id_token=raw_id_token,
|
||||
)
|
||||
ccchh_roles: List[str]
|
||||
|
||||
|
||||
class LockStatus(BaseModel):
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
#
|
||||
# This whole implementation is adapted from the upstream GitHub example
|
||||
# https://github.com/eclipse-paho/paho.mqtt.python/blob/master/examples/loop_asyncio.py
|
||||
#
|
||||
|
||||
from typing import Any, Set, Iterable
|
||||
import logging
|
||||
import asyncio
|
||||
import socket
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncLooper:
|
||||
"""
|
||||
Helper class to implement loopgin with asyncio for the underlying mqtt IO
|
||||
"""
|
||||
|
||||
def __init__(self, loop: asyncio.AbstractEventLoop, client: mqtt.Client):
|
||||
self.loop = loop
|
||||
|
||||
self.client = client
|
||||
self.client.on_socket_open = self.on_socket_open
|
||||
self.client.on_socket_close = self.on_socket_close
|
||||
self.client.on_socket_register_write = self.on_socket_register_write
|
||||
self.client.on_socket_unregister_write = self.on_socket_unregister_write
|
||||
|
||||
def on_socket_open(self, client, userdata, sock):
|
||||
def cb():
|
||||
client.loop_read()
|
||||
|
||||
self.loop.add_reader(sock, cb)
|
||||
self.task_misc = self.loop.create_task(self.misc_loop())
|
||||
|
||||
def on_socket_close(self, client, userdata, sock):
|
||||
self.loop.remove_reader(sock)
|
||||
self.task_misc.cancel()
|
||||
|
||||
def on_socket_register_write(self, client, userdata, sock):
|
||||
def cb():
|
||||
client.loop_write()
|
||||
|
||||
self.loop.add_writer(sock, cb)
|
||||
|
||||
def on_socket_unregister_write(self, client, userdata, sock):
|
||||
self.loop.remove_writer(sock)
|
||||
|
||||
async def misc_loop(self):
|
||||
while self.client.loop_misc() == mqtt.MQTT_ERR_SUCCESS:
|
||||
try:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
|
||||
class AsyncMqttClient:
|
||||
loop: asyncio.AbstractEventLoop
|
||||
connection_string: str
|
||||
looper: AsyncLooper
|
||||
client: mqtt.Client
|
||||
active_subscriptions: Set[str]
|
||||
messages: asyncio.Queue
|
||||
|
||||
def __init__(self, connection_string: str, username: str, password: str):
|
||||
self.connection_string = connection_string
|
||||
self.active_subscriptions = set()
|
||||
self.messages = asyncio.Queue()
|
||||
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="dooris")
|
||||
self.client.username = username
|
||||
self.client.password = password
|
||||
self.client.on_connect = self.on_connect
|
||||
self.client.on_connect_fail = self.on_connect_fail
|
||||
self.client.on_message = self.on_message
|
||||
self.client.on_disconnect = self.on_disconnect
|
||||
self.client.on_subscribe = self.on_subscribe
|
||||
self.client.on_unsubscribe = self.on_unsubscribe
|
||||
self.client.on_disconnect = self.on_disconnect
|
||||
|
||||
def on_connect(
|
||||
self,
|
||||
client: mqtt.Client,
|
||||
userdata: Any,
|
||||
flags: mqtt.ConnectFlags,
|
||||
reason_code,
|
||||
properties: mqtt.Properties,
|
||||
):
|
||||
logger.debug(f"mqtt client connected with message '{reason_code}'")
|
||||
self.fut_connected.set_result(None)
|
||||
|
||||
def on_connect_fail(self, client, userdata):
|
||||
logger.error("mqtt client could not connect to broker")
|
||||
self.fut_connected.set_exception(
|
||||
Exception("mqtt client could not connect to broker")
|
||||
)
|
||||
|
||||
def on_disconnect(
|
||||
self,
|
||||
client: mqtt.Client,
|
||||
userdata: Any,
|
||||
flags: mqtt.DisconnectFlags,
|
||||
reason_code,
|
||||
properties: mqtt.Properties,
|
||||
):
|
||||
logger.debug(f"mqtt client disconnected with message '{reason_code}'")
|
||||
if self.fut_disconnect:
|
||||
self.fut_disconnect.set_result(None)
|
||||
|
||||
def on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage):
|
||||
self.messages.put_nowait(msg)
|
||||
|
||||
def on_subscribe(self, client, userdata, mid, reason_code, properties):
|
||||
logger.debug(f"mqtt client subscribed to topics with message '{reason_code}'")
|
||||
self.fut_subscribe.set_result(None)
|
||||
|
||||
def on_unsubscribe(self, client, userdata, mid, reason_code, properties):
|
||||
logger.debug(
|
||||
f"mqtt client unsubscribed from topics with message '{reason_code}'"
|
||||
)
|
||||
self.fut_unsubscribe.set_result(None)
|
||||
|
||||
async def update_subscriptions(self, topics: Iterable[str], qos: int = 1):
|
||||
"""
|
||||
Update MQTT subscriptions so that the client is subscribed to exactly the given list of topics
|
||||
"""
|
||||
|
||||
to_add = topics.difference(self.active_subscriptions)
|
||||
if to_add:
|
||||
self.active_subscriptions.update(to_add)
|
||||
if self.is_connected:
|
||||
logger.info(f"mqtt client subscribing to topics {', '.join(to_add)}")
|
||||
self.fut_subscribe = asyncio.get_running_loop().create_future()
|
||||
self.client.subscribe([(i, qos) for i in to_add])
|
||||
await self.fut_subscribe
|
||||
|
||||
to_remove = self.active_subscriptions.difference(topics)
|
||||
if to_remove:
|
||||
self.active_subscriptions.difference_update(to_remove)
|
||||
if self.is_connected:
|
||||
logger.info(f"mqtt client unsubscribing from topics {','.join(to_remove)}")
|
||||
self.fut_unsubscribe = asyncio.get_running_loop().create_future()
|
||||
self.client.unsubscribe(list(to_remove))
|
||||
await self.fut_unsubscribe
|
||||
|
||||
async def connect(self):
|
||||
server_host, server_port = self.connection_string.rsplit(":", maxsplit=1)
|
||||
|
||||
self.looper = AsyncLooper(asyncio.get_running_loop(), self.client)
|
||||
|
||||
logger.info("Connecting to mqtt server at %s:%s", server_host, server_port)
|
||||
self.fut_connected = asyncio.get_running_loop().create_future()
|
||||
self.client.connect(server_host, int(server_port))
|
||||
self.client.socket().setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 2048)
|
||||
|
||||
await self.fut_connected
|
||||
|
||||
# re-establish all supposed mqtt subscriptions
|
||||
if len(self.active_subscriptions) > 0:
|
||||
qos = 1
|
||||
self.fut_subscribe = asyncio.get_running_loop().create_future()
|
||||
self.client.subscribe([(i, qos) for i in self.active_subscriptions])
|
||||
await self.fut_subscribe
|
||||
|
||||
async def disconnect(self):
|
||||
if not self.is_connected:
|
||||
return
|
||||
|
||||
logger.info("Disconnecting mqtt client from broker")
|
||||
self.fut_disconnect = asyncio.get_running_loop().create_future()
|
||||
self.client.disconnect()
|
||||
await self.fut_disconnect
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self.client.is_connected()
|
||||
11
api/uv.lock
generated
11
api/uv.lock
generated
|
|
@ -400,7 +400,6 @@ source = { editable = "." }
|
|||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "paho-mqtt" },
|
||||
{ name = "simple-openid-connect" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
|
@ -414,7 +413,6 @@ dev = [
|
|||
requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.13.5" },
|
||||
{ name = "fastapi", specifier = ">=0.136.1" },
|
||||
{ name = "paho-mqtt", specifier = ">=2.1.0" },
|
||||
{ name = "simple-openid-connect", specifier = ">=2.4.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.46.0" },
|
||||
]
|
||||
|
|
@ -736,15 +734,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/b2/6c/d8a02ffb24876b5f51fbd781f479fc6525a518553a4196bd0433dae9ff8e/orderedmultidict-1.0.2-py2.py3-none-any.whl", hash = "sha256:ab5044c1dca4226ae4c28524cfc5cc4c939f0b49e978efa46a6ad6468049f79b", size = 11897, upload-time = "2025-11-18T08:00:41.44Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paho-mqtt"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parso"
|
||||
version = "0.8.7"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# Update schema
|
||||
|
||||
`pnpm dlx openapi-typescript http://localhost:8000/api/openapi.json -o ./schema.ts`
|
||||
`pnpm dlx openapi-typescript http://localhost:8000/openapi.json -o ./schema.ts`
|
||||
|
|
|
|||
|
|
@ -89,23 +89,6 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/locks/stream": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Watch Locks */
|
||||
get: operations["watch_locks_api_locks_stream_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/locks/{lock_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -127,21 +110,6 @@ export interface paths {
|
|||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
/** ApiUser */
|
||||
ApiUser: {
|
||||
/** Is Anonymous */
|
||||
is_anonymous: boolean;
|
||||
/** Is Ccchh User */
|
||||
is_ccchh_user: boolean;
|
||||
/** Is Token User */
|
||||
is_token_user: boolean;
|
||||
/** May Operate Locks */
|
||||
may_operate_locks: boolean;
|
||||
/** Username */
|
||||
username: string;
|
||||
/** Guaranteed Session Until */
|
||||
guaranteed_session_until: string | null;
|
||||
};
|
||||
/** HTTPValidationError */
|
||||
HTTPValidationError: {
|
||||
/** Detail */
|
||||
|
|
@ -167,7 +135,7 @@ export interface components {
|
|||
* @description Statically known HTTP problem types using the [type URI scheme](https://datatracker.ietf.org/doc/rfc4151/)
|
||||
* @enum {string}
|
||||
*/
|
||||
HttpProblemType: "type:noc@hamburg.ccc.de,2026:UNAUTHORIZED" | "type:noc@hamburg.ccc.de,2026,FORBIDDEN_TO_OPERATE" | "type:noc@hamburg.ccc.de,2026:LOCK_NOT_FOUND";
|
||||
HttpProblemType: "type:noc@hamburg.ccc.de,2026:UNAUTHORIZED" | "type:noc@hamburg.ccc.de,2026:LOCK_NOT_FOUND";
|
||||
/** Lock */
|
||||
Lock: {
|
||||
/** Name */
|
||||
|
|
@ -208,6 +176,17 @@ export interface components {
|
|||
*/
|
||||
activity_state: "unknown" | "locking" | "unlocking" | "stable";
|
||||
};
|
||||
/** UserStatus */
|
||||
UserStatus: {
|
||||
/** Is Logged In */
|
||||
is_logged_in: boolean;
|
||||
/** Is Authorized */
|
||||
is_authorized: boolean;
|
||||
/** Guaranteed Session Until */
|
||||
guaranteed_session_until: string | null;
|
||||
/** Username */
|
||||
username: string | null;
|
||||
};
|
||||
/** ValidationError */
|
||||
ValidationError: {
|
||||
/** Location */
|
||||
|
|
@ -245,7 +224,16 @@ export interface operations {
|
|||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiUser"];
|
||||
"application/json": components["schemas"]["UserStatus"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HttpProblemDetail"];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -344,35 +332,6 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
watch_locks_api_locks_stream_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"text/event-stream": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"text/event-stream": components["schemas"]["HttpProblemDetail"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
operate_lock_api_locks__lock_id__patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -406,15 +365,6 @@ export interface operations {
|
|||
"application/json": components["schemas"]["HttpProblemDetail"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HttpProblemDetail"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import {Fetcher} from "openapi-typescript-fetch"
|
||||
import type {components, paths} from "../api/schema"
|
||||
import type {paths} from "../api/schema"
|
||||
import type {ui} from "../i18n/ui.ts"
|
||||
|
||||
const fetcher = Fetcher.for<paths>()
|
||||
|
|
@ -29,7 +29,7 @@ declare global {
|
|||
lang: keyof typeof ui;
|
||||
doors: Array<DoorType>;
|
||||
auth: AuthType;
|
||||
doorAction: (action: "unlock" | "lock", doorId: string) => void;
|
||||
doorAction: (action: 'unlock' | 'lock', doorId: string) => void;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,32 +93,30 @@ async function checkUser() {
|
|||
const {data: userInfo} = await getUserInfo({})
|
||||
|
||||
apiError.current = null
|
||||
auth.authenticated = !userInfo.is_anonymous
|
||||
auth.authorized = userInfo.may_operate_locks
|
||||
auth.authenticated = true
|
||||
auth.authorized = userInfo.is_authorized
|
||||
auth.until = userInfo.guaranteed_session_until ? new Date(userInfo.guaranteed_session_until) : null
|
||||
auth.username = userInfo.username ?? ""
|
||||
auth.recentLogout = false
|
||||
|
||||
if (auth.authenticated) {
|
||||
triggerAuthTimeout()
|
||||
}
|
||||
triggerAuthTimeout()
|
||||
} catch (e) {
|
||||
// check which operation threw the exception
|
||||
if (e instanceof getUserInfo.Error) {
|
||||
const error = e.getActualType()
|
||||
|
||||
|
||||
if (error.status >= 500 && error.status < 600) {
|
||||
if (error.status === 401) {
|
||||
if (!auth.recentLogout)
|
||||
auth.recentLogout = auth.authenticated // set recentLogout true, if user was logged in before
|
||||
auth.authenticated = false
|
||||
auth.authorized = false
|
||||
auth.until = null
|
||||
auth.username = ""
|
||||
} else if (error.status >= 500 && error.status < 600) {
|
||||
apiError.current = "serverError"
|
||||
} else {
|
||||
console.error("unknown error:", error)
|
||||
}
|
||||
|
||||
if (!auth.recentLogout)
|
||||
auth.recentLogout = auth.authenticated // set recentLogout true, if user was logged in before
|
||||
auth.authenticated = false
|
||||
auth.authorized = false
|
||||
auth.until = null
|
||||
auth.username = ""
|
||||
|
||||
}
|
||||
} finally {
|
||||
localStorage.setItem("auth", JSON.stringify(auth))
|
||||
|
|
@ -127,24 +125,15 @@ async function checkUser() {
|
|||
}
|
||||
}
|
||||
|
||||
async function subscribeDoorEvents() {
|
||||
async function fetchDoors() {
|
||||
if (doors.length === 0) {
|
||||
loading.doors = true
|
||||
}
|
||||
refresh()
|
||||
const getDoors = fetcher.path("/api/locks/").method("get").create()
|
||||
|
||||
const evtSource = new EventSource("/api/locks/stream")
|
||||
|
||||
evtSource.onerror = () => {
|
||||
if (!window.navigator.onLine) {
|
||||
apiError.current = "networkError"
|
||||
} else {
|
||||
apiError.current = "serverError"
|
||||
}
|
||||
}
|
||||
|
||||
evtSource.onmessage = (event) => {
|
||||
const doorInfo: Array<components["schemas"]["Lock"]> = JSON.parse(event.data)
|
||||
try {
|
||||
const {data: doorInfo} = await getDoors({})
|
||||
|
||||
apiError.current = null
|
||||
while (doors.length) {
|
||||
|
|
@ -175,6 +164,29 @@ async function subscribeDoorEvents() {
|
|||
|
||||
loading.doors = false
|
||||
refresh()
|
||||
} catch (e) {
|
||||
// check which operation threw the exception
|
||||
if (e instanceof getDoors.Error) {
|
||||
const error = e.getActualType()
|
||||
|
||||
if (error.status === 401) {
|
||||
console.log("unauthorized")
|
||||
loading.doors = false
|
||||
refresh()
|
||||
} else if (error.status >= 500 && error.status < 600) {
|
||||
apiError.current = "serverError"
|
||||
clearInterval(doorsInterval)
|
||||
} else {
|
||||
console.error("unknown error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
if (e instanceof Error) {
|
||||
switch (e.name) {
|
||||
case "TypeError":
|
||||
apiError.current = "networkError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -283,7 +295,7 @@ function refresh() {
|
|||
|
||||
|
||||
loadAuthFromLocalStorage()
|
||||
subscribeDoorEvents()
|
||||
const doorsInterval = setInterval(fetchDoors, 250) // TODO: replace with SSE
|
||||
checkUser()
|
||||
|
||||
document.addEventListener("loadeddata", () => {
|
||||
|
|
|
|||
|
|
@ -96,4 +96,31 @@ export const ui = {
|
|||
"chat.dooris": "dOwOris",
|
||||
"chat.user": "my fren :3",
|
||||
},
|
||||
} as const
|
||||
meow: {
|
||||
"dooris": "meoowris 🐈️ <span class='opacity-50 text-xs'>(DOORIS)</span>",
|
||||
"unauthorized.title": "rawwwwr 😾⛔️",
|
||||
"unauthorized.description": `meow mreoow: <a class='underline' href='https://wiki.hamburg.ccc.de/club:prozesse:aufnahmeprozesse-fuer-berechtigungsgruppen'>🌐 meow</a>`,
|
||||
"unauthenticated.title": "meow? 🫴🐈️",
|
||||
"unauthenticated.description": `meow mreoow: <a class='underline' href='https://wiki.hamburg.ccc.de/club:prozesse:aufnahmeprozesse-fuer-berechtigungsgruppen'>🌐 meow</a>`,
|
||||
"state.unlocked": "mrrp 🐱🔓️",
|
||||
"state.locked": "mreoww 🐱🔒️",
|
||||
"state.unknown": "meoooow????? 🙀",
|
||||
"state.unlocking": "meeeow 😺🔁🔓️",
|
||||
"state.locking": "mreowww 😿🔁🔒️",
|
||||
"lock.batteryLow": "mrrrrrp flop 🪫",
|
||||
"lock.unreachable": "meooww?? 🙀🚫❓️🚪",
|
||||
"lock.jammed": "mew 🚪🔁🚫",
|
||||
"button.open": "🐈️ 👉👈🔓️",
|
||||
"button.close": "🐈️ 🔒️",
|
||||
"login": "meow? 🫴🐈️",
|
||||
"loggedOut.title": "mew mew 😿",
|
||||
"loggedOut.description": `meow 🐱⌛️`,
|
||||
"serverError.title": "🖥️⛓️💥",
|
||||
"serverError.description": `mew mew ⌚️`,
|
||||
"networkError.title": "meww 🛜🚫",
|
||||
"networkError.description": `🛜😺`,
|
||||
"loadingDoors": '🔁',
|
||||
"chat.dooris": "meoowris",
|
||||
"chat.user": "😻",
|
||||
},
|
||||
} as const
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue