All checks were successful
Build Container / Build Container (push) Successful in 1m26s
282 lines
9.8 KiB
Python
282 lines
9.8 KiB
Python
from typing import Optional, List, AsyncIterable
|
|
import logging
|
|
import secrets
|
|
import asyncio
|
|
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
|
|
from aiohttp import BasicAuth
|
|
|
|
from dooris_api import deps, models, exceptions, app_config
|
|
from dooris_api.ccujack import CCUJackClient
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
app_cfg = app_config.get()
|
|
|
|
app.extra["oidc_client"] = OpenidClient.from_issuer_url(
|
|
url=app_cfg.openid_issuer,
|
|
authentication_redirect_uri=f"{app_cfg.base_url}/auth/login-callback",
|
|
client_id=app_cfg.openid_client_id,
|
|
client_secret=app_cfg.openid_client_secret,
|
|
scope=app_cfg.openid_scope,
|
|
)
|
|
|
|
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,
|
|
)
|
|
await app.extra["ccujack"].start()
|
|
|
|
yield
|
|
|
|
await app.extra["ccujack"].close_connections()
|
|
|
|
|
|
app = FastAPI(
|
|
title="Dooris",
|
|
summary="Server to interact with CCCHH doors via locks over CCUJACK over HomeMatic over WiFi",
|
|
openapi_url="/api/openapi.json",
|
|
docs_url="/api/docs",
|
|
lifespan=lifespan,
|
|
)
|
|
app.add_exception_handler(
|
|
exceptions.HttpProblemException, exceptions.problem_exception_handler
|
|
)
|
|
|
|
|
|
@app.get(
|
|
"/api/user-info/",
|
|
name="get-user-info",
|
|
tags=["auth"],
|
|
)
|
|
async def get_user_info(req: Request, current_user: deps.ApiUser) -> models.ApiUser:
|
|
return current_user
|
|
|
|
|
|
@app.get("/auth/login", tags=["auth"], response_class=RedirectResponse, status_code=302)
|
|
async def login_init(
|
|
req: Request,
|
|
resp: Response,
|
|
oidc_client: deps.OpenidClient,
|
|
next: Optional[str] = "",
|
|
) -> str:
|
|
logger.debug("starting user authentication with upstream identity provider")
|
|
|
|
# save the ?next url for later redirection if the user requested that
|
|
if next:
|
|
resp.set_cookie("auth_next", next, max_age=60 * 10, httponly=True, secure=True)
|
|
|
|
# save the login state into a cookie to prevent CSRF attacks
|
|
# ref: https://simple-openid-connect.readthedocs.io/en/stable/nonce_and_state.html
|
|
state = secrets.token_urlsafe(32)
|
|
resp.set_cookie("auth_state", state, max_age=60 * 10, httponly=True, secure=True)
|
|
resp.set_cookie(
|
|
"auth_start_time",
|
|
datetime.now(UTC).timestamp(),
|
|
max_age=60 * 10,
|
|
httponly=True,
|
|
secure=True,
|
|
)
|
|
|
|
# prevent replay attacks by generating and specifying a nonce
|
|
# ref: https://simple-openid-connect.readthedocs.io/en/stable/nonce_and_state.html
|
|
nonce = secrets.token_urlsafe(32)
|
|
|
|
# assemble the response
|
|
resp.set_cookie("auth_nonce", nonce, max_age=60 * 10, httponly=True, secure=True)
|
|
|
|
return oidc_client.authorization_code_flow.start_authentication(
|
|
state=state, nonce=nonce
|
|
)
|
|
|
|
|
|
@app.get(
|
|
"/auth/login-callback",
|
|
tags=["auth"],
|
|
response_class=RedirectResponse,
|
|
status_code=302,
|
|
)
|
|
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 (
|
|
"auth_state" not in req.cookies
|
|
or "auth_nonce" not in req.cookies
|
|
or "auth_start_time" not in req.cookies
|
|
):
|
|
logger.debug(
|
|
"user tried to log in but cookies indicate they are in a wrong state; redirecting to error view"
|
|
)
|
|
return "/auth/login-error?error=todo"
|
|
|
|
# ensure cookies are always cleared in the response
|
|
resp.set_cookie("auth_state", "", max_age=0)
|
|
resp.set_cookie("auth_start_time", "", max_age=0)
|
|
|
|
# perform the actual authentication via OIDC token exchange
|
|
auth_result = oidc_client.authorization_code_flow.handle_authentication_result(
|
|
current_url=req.url, state=req.cookies["auth_state"]
|
|
)
|
|
|
|
# save the authentication result for later reuse
|
|
if isinstance(auth_result, TokenSuccessResponse):
|
|
auth_start_time = datetime.fromtimestamp(
|
|
float(req.cookies["auth_start_time"]), UTC
|
|
)
|
|
deps.persist_oidc_auth_state(
|
|
oidc_client, resp, auth_result, auth_start_time, req.cookies["auth_nonce"]
|
|
)
|
|
logger.debug("successfully authenticated user")
|
|
|
|
# respect originally requested ?next= url and reset the storage
|
|
resp.set_cookie("auth_next", "", max_age=0)
|
|
if "auth_next" in req.cookies:
|
|
return req.cookies["auth_next"]
|
|
else:
|
|
return "/"
|
|
else:
|
|
logger.debug(
|
|
"could not authenticate user because of OIDC error; redirecting to error page with error messages intact"
|
|
)
|
|
return f"/login-error?{req.query_params}"
|
|
|
|
|
|
@app.get(
|
|
"/auth/logout", tags=["auth"], response_class=RedirectResponse, status_code=302
|
|
)
|
|
async def logout(
|
|
resp: Response, oidc_client: deps.OpenidClient, current_user: deps.AuthenticatedUser
|
|
) -> str:
|
|
deps.clear_oidc_auth_state(resp)
|
|
return oidc_client.initiate_logout(
|
|
RpInitiatedLogoutRequest(
|
|
id_token_hint=current_user.raw_id_token,
|
|
post_logout_redirect_uri=f"{app_config.get().base_url}/",
|
|
)
|
|
)
|
|
|
|
|
|
@app.get(
|
|
"/api/locks/",
|
|
tags=["locks"],
|
|
responses={status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail}},
|
|
)
|
|
async def list_locks(ccujack: deps.CCUJackClient) -> List[models.Lock]:
|
|
# assemble result objects
|
|
result = []
|
|
for i_lock, lock_channels in ccujack.locks:
|
|
status_data = dict()
|
|
for i_channel, channel_params in lock_channels:
|
|
for i_param in channel_params:
|
|
value = await ccujack.query_param_value(
|
|
f"{i_lock.address}/{i_channel.index}/{i_param.id}"
|
|
)
|
|
match i_param.id:
|
|
case "LOCK_STATE":
|
|
match value.v:
|
|
case 0:
|
|
status_data["lock_state"] = "unknown"
|
|
case 1:
|
|
status_data["lock_state"] = "locked"
|
|
case 2:
|
|
status_data["lock_state"] = "unlocked"
|
|
case "ACTIVITY_STATE":
|
|
match value.v:
|
|
case 1:
|
|
status_data["activity_state"] = "unlocking"
|
|
case 2:
|
|
status_data["activity_state"] = "locking"
|
|
case 3:
|
|
status_data["activity_state"] = "stable"
|
|
case "LOCK_TARGET_LEVEL":
|
|
match value.v:
|
|
case 0:
|
|
status_data["lock_target_level"] = "locked"
|
|
case 1:
|
|
status_data["lock_target_level"] = "unlocked"
|
|
case 2:
|
|
status_data["lock_target_level"] = "open"
|
|
case "LOW_BAT":
|
|
status_data["is_low_battery"] = value.v
|
|
case "ERROR_JAMMED":
|
|
status_data["is_error_jammed"] = value.v
|
|
case "UNREACH":
|
|
status_data["is_unreachable"] = value.v
|
|
|
|
result.append(
|
|
models.Lock(
|
|
id=i_lock.identifier,
|
|
name=i_lock.title,
|
|
status=models.LockStatus(**status_data),
|
|
)
|
|
)
|
|
|
|
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"],
|
|
responses={
|
|
status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail},
|
|
status.HTTP_403_FORBIDDEN: {"model": models.HttpProblemDetail},
|
|
status.HTTP_404_NOT_FOUND: {"model": models.HttpProblemDetail},
|
|
},
|
|
)
|
|
async def operate_lock(
|
|
req: Request,
|
|
lock_id: str,
|
|
requested_op: models.LockOperation,
|
|
ccujack: deps.CCUJackClient,
|
|
current_user: deps.AuthenticatedUser,
|
|
) -> None:
|
|
if not current_user.may_operate_locks:
|
|
raise exceptions.HttpProblemException.forbidden_to_operate(req.url)
|
|
|
|
# find appropriate lock from ccujack
|
|
for i_lock, lock_channels in ccujack.locks:
|
|
if i_lock.identifier == lock_id:
|
|
for i_channel, channel_params in lock_channels:
|
|
if i_channel.type == "DOOR_LOCK_STATE_TRANSMITTER":
|
|
for i_param in channel_params:
|
|
if i_param.id == "LOCK_TARGET_LEVEL":
|
|
addr = f"{i_lock.address}/{i_channel.index}/{i_param.id}"
|
|
|
|
# match readable request parameter to ccujack value
|
|
match requested_op.desired_state:
|
|
case "closed":
|
|
ccujack_value = 0
|
|
case "open":
|
|
ccujack_value = 1
|
|
|
|
# write to ccujack
|
|
await ccujack.set_param_value(addr, ccujack_value)
|
|
return
|
|
|
|
else:
|
|
raise exceptions.HttpProblemException.new_lock_not_found(lock_id, req.url)
|