api: implement logout endpoint

This commit is contained in:
lilly 2026-05-14 15:27:36 +02:00
commit 07c72c752f
Signed by: lilly
SSH key fingerprint: SHA256:y9T5GFw2A20WVklhetIxG1+kcg/Ce0shnQmbu1LQ37g
3 changed files with 200 additions and 64 deletions

View file

@ -8,11 +8,11 @@ from fastapi import FastAPI, Request, Response, status
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from simple_openid_connect.client import OpenidClient from simple_openid_connect.client import OpenidClient
from simple_openid_connect.data import TokenSuccessResponse from simple_openid_connect.data import TokenSuccessResponse, RpInitiatedLogoutRequest
from cachetools import TTLCache from cachetools import TTLCache
from aiohttp import BasicAuth from aiohttp import BasicAuth
from dooris_api import deps, models, exceptions from dooris_api import deps, models, exceptions, app_config
from dooris_api.ccujack import CCUJackClient from dooris_api.ccujack import CCUJackClient
@ -21,8 +21,7 @@ logger = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
from dooris_api import app_config app_cfg = app_config.get()
app_config = app_config.get()
root_logger = logging.getLogger("") root_logger = logging.getLogger("")
root_logger.setLevel(logging.INFO) root_logger.setLevel(logging.INFO)
@ -31,42 +30,68 @@ async def lifespan(app: FastAPI):
app_logger.setLevel(logging.DEBUG) app_logger.setLevel(logging.DEBUG)
app.extra["oidc_client"] = OpenidClient.from_issuer_url( app.extra["oidc_client"] = OpenidClient.from_issuer_url(
url=app_config.openid_issuer, url=app_cfg.openid_issuer,
authentication_redirect_uri=f"{app_config.base_url}/auth/login-callback", authentication_redirect_uri=f"{app_cfg.base_url}/auth/login-callback",
client_id=app_config.openid_client_id, client_id=app_cfg.openid_client_id,
client_secret=app_config.openid_client_secret, client_secret=app_cfg.openid_client_secret,
scope=app_config.openid_scope, scope=app_cfg.openid_scope,
) )
app.extra["cache"] = TTLCache(maxsize=64, ttl=30 * 60) app.extra["cache"] = TTLCache(maxsize=64, ttl=30 * 60)
app.extra["ccujack"] = CCUJackClient("https://hmdooris-ccu.ccchh.net:2122", auth=BasicAuth("dooris", os.environ["HMDOORIS_PW"])) app.extra["ccujack"] = CCUJackClient(
"https://hmdooris-ccu.ccchh.net:2122",
auth=BasicAuth("dooris", os.environ["HMDOORIS_PW"]),
)
yield yield
app = FastAPI( app = FastAPI(
title="Dooris", summary="Server to interact with CCCHH doors via locks over CCUJACK over HomeMatic over WiFi", docs_url="/api/docs", title="Dooris",
summary="Server to interact with CCCHH doors via locks over CCUJACK over HomeMatic over WiFi",
docs_url="/api/docs",
lifespan=lifespan, lifespan=lifespan,
) )
app.add_exception_handler(exceptions.HttpProblemException, exceptions.problem_exception_handler) app.add_exception_handler(
exceptions.HttpProblemException, exceptions.problem_exception_handler
)
@app.get("/api/user-info/", name="get-user-info", tags=["auth"], responses={status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail}}) @app.get(
async def get_user_info(req: Request, current_user: deps.CurrentUser) -> models.UserStatus: "/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.CurrentUser
) -> models.UserStatus:
if current_user is None: if current_user is None:
return models.UserStatus(is_logged_in=False, is_authorized=False, username=None, guaranteed_session_until=None) return models.UserStatus(
is_logged_in=False,
is_authorized=False,
username=None,
guaranteed_session_until=None,
)
else: else:
return models.UserStatus( return models.UserStatus(
is_logged_in=True, is_logged_in=True,
is_authorized=True, is_authorized=True,
guaranteed_session_until=datetime.fromtimestamp(current_user.id_token.exp, UTC), guaranteed_session_until=datetime.fromtimestamp(
current_user.id_token.exp, UTC
),
username=current_user.id_token.preferred_username, username=current_user.id_token.preferred_username,
) )
@app.get("/auth/login", tags=["auth"], response_class=RedirectResponse, status_code=302) @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: 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") logger.debug("starting user authentication with upstream identity provider")
# save the ?next url for later redirection if the user requested that # save the ?next url for later redirection if the user requested that
@ -77,7 +102,13 @@ async def login_init(req: Request, resp: Response, oidc_client: deps.OpenidClien
# ref: https://simple-openid-connect.readthedocs.io/en/stable/nonce_and_state.html # ref: https://simple-openid-connect.readthedocs.io/en/stable/nonce_and_state.html
state = secrets.token_urlsafe(32) state = secrets.token_urlsafe(32)
resp.set_cookie("auth_state", state, max_age=60 * 10, httponly=True, secure=True) 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) 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 # prevent replay attacks by generating and specifying a nonce
# ref: https://simple-openid-connect.readthedocs.io/en/stable/nonce_and_state.html # ref: https://simple-openid-connect.readthedocs.io/en/stable/nonce_and_state.html
@ -86,15 +117,28 @@ async def login_init(req: Request, resp: Response, oidc_client: deps.OpenidClien
# assemble the response # assemble the response
resp.set_cookie("auth_nonce", nonce, max_age=60 * 10, httponly=True, secure=True) 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) 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) @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): async def login_callback(req: Request, resp: Response, oidc_client: deps.OpenidClient):
# check that the user is currently in an authenticating state # check that the user is currently in an authenticating state
# these cookies are set by the login_init() view # 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: if (
logger.debug("user tried to log in but cookies indicate they are in a wrong state; redirecting to error view") "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" return "/auth/login-error?error=todo"
# ensure cookies are always cleared in the response # ensure cookies are always cleared in the response
@ -102,24 +146,50 @@ async def login_callback(req: Request, resp: Response, oidc_client: deps.OpenidC
resp.set_cookie("auth_start_time", "", max_age=0) resp.set_cookie("auth_start_time", "", max_age=0)
# perform the actual authentication via OIDC token exchange # 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"]) 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 # save the authentication result for later reuse
if isinstance(auth_result, TokenSuccessResponse): if isinstance(auth_result, TokenSuccessResponse):
auth_start_time = datetime.fromtimestamp(float(req.cookies["auth_start_time"]), UTC) auth_start_time = datetime.fromtimestamp(
deps.persist_auth_state(oidc_client, resp, auth_result, auth_start_time, req.cookies["auth_nonce"]) float(req.cookies["auth_start_time"]), UTC
)
deps.persist_auth_state(
oidc_client, resp, auth_result, auth_start_time, req.cookies["auth_nonce"]
)
# redirect the user to the page they wanted to visit # redirect the user to the page they wanted to visit
# TODO: respect "auth_next" cookie to redirect the user to a specific url # TODO: respect "auth_next" cookie to redirect the user to a specific url
logger.debug("successfully authenticated user") logger.debug("successfully authenticated user")
return str(req.url_for("get-user-info")) return str(req.url_for("get-user-info"))
else: else:
logger.debug("could not authenticate user because of OIDC error; redirecting to error page with error messages intact") logger.debug(
"could not authenticate user because of OIDC error; redirecting to error page with error messages intact"
)
return f"/auth/login-error?{req.query_params}" return f"/auth/login-error?{req.query_params}"
@app.get("/api/locks/", tags=["locks"], responses={status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail}}) @app.get(
async def list_locks(ccujack: deps.CCUJackClient, cache: deps.Cache) -> List[models.Lock]: "/auth/logout", tags=["auth"], response_class=RedirectResponse, status_code=302
)
async def logout(
resp: Response, oidc_client: deps.OpenidClient, current_user: deps.CurrentUser
):
deps.clear_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, cache: deps.Cache
) -> List[models.Lock]:
# discover locks from ccujack # discover locks from ccujack
CACHE_KEY = "ccu-find-locks" CACHE_KEY = "ccu-find-locks"
if CACHE_KEY in cache: if CACHE_KEY in cache:
@ -134,7 +204,9 @@ async def list_locks(ccujack: deps.CCUJackClient, cache: deps.Cache) -> List[mod
status_data = dict() status_data = dict()
for i_channel, channel_params in lock_channels: for i_channel, channel_params in lock_channels:
for i_param in channel_params: for i_param in channel_params:
value = await ccujack.query_param_value(f"{i_lock.address}/{i_channel.index}/{i_param.id}") value = await ccujack.query_param_value(
f"{i_lock.address}/{i_channel.index}/{i_param.id}"
)
match i_param.id: match i_param.id:
case "LOCK_STATE": case "LOCK_STATE":
match value.v: match value.v:
@ -167,9 +239,8 @@ async def list_locks(ccujack: deps.CCUJackClient, cache: deps.Cache) -> List[mod
case "UNREACH": case "UNREACH":
status_data["is_unreachable"] = value.v status_data["is_unreachable"] = value.v
result.append(models.Lock(name=i_lock.title, status=models.LockStatus(**status_data))) result.append(
models.Lock(name=i_lock.title, status=models.LockStatus(**status_data))
)
return result return result

View file

@ -20,17 +20,25 @@ async def get_oidc_client(req: Request) -> OpenidClient:
OpenidClient = Annotated[OpenidClient, Depends(get_oidc_client)] OpenidClient = Annotated[OpenidClient, Depends(get_oidc_client)]
async def get_current_user(req: Request, resp: Response, oidc_client: OpenidClient) -> Optional[models.CurrentUser]: async def get_current_user(
req: Request, resp: Response, oidc_client: OpenidClient
) -> Optional[models.CurrentUser]:
# easiest case: we still have an access token (which is the most fleeting component) # 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 # 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", "auth_nonce")): 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") logger.debug(
id_token = oidc_client.decode_id_token(req.cookies["id_token"], nonce=req.cookies["auth_nonce"]) "user is fully authenticated, returning current user from existing id_token"
return models.CurrentUser(id_token=id_token) )
id_token = oidc_client.decode_id_token(
req.cookies["id_token"], nonce=req.cookies["auth_nonce"]
)
return models.CurrentUser(id_token=id_token, raw_id_token=req.cookies["id_token"])
# if we have a refresh token, try to get new tokens # if we have a refresh token, try to get new tokens
if all(i in req.cookies for i in ("refresh_token", "auth_nonce")): if 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") logger.debug(
"user has been previously authenticated, trying to recover with refresh_token"
)
auth_start_time = datetime.now(UTC) auth_start_time = datetime.now(UTC)
token_resp = oidc_client.exchange_refresh_token(req.cookies["refresh_token"]) token_resp = oidc_client.exchange_refresh_token(req.cookies["refresh_token"])
if isinstance(token_resp, TokenSuccessResponse): if isinstance(token_resp, TokenSuccessResponse):
@ -38,14 +46,22 @@ async def get_current_user(req: Request, resp: Response, oidc_client: OpenidClie
# return the newly gotten info # return the newly gotten info
id_token = oidc_client.decode_id_token(token_resp.id_token) id_token = oidc_client.decode_id_token(token_resp.id_token)
return models.CurrentUser(id_token=id_token) return models.CurrentUser(id_token=id_token, raw_id_token=token_resp.id_token)
# otherwise we can't meaningfully recover any user information or the user is simply not authenticated # otherwise we can't meaningfully recover any user information or the user is simply not authenticated
logger.debug("no currently authenticated user") logger.debug("no currently authenticated user")
raise exceptions.HttpProblemException(models.HttpProblemDetail.new_unauthorized(req.url)) raise exceptions.HttpProblemException(
models.HttpProblemDetail.new_unauthorized(req.url)
)
def persist_auth_state(oidc_client: OpenidClient, resp: Response, tokens: TokenSuccessResponse, auth_start_time: datetime, token_nonce: Optional[str] = None): def persist_auth_state(
oidc_client: OpenidClient,
resp: Response,
tokens: TokenSuccessResponse,
auth_start_time: datetime,
token_nonce: Optional[str] = None,
):
now = datetime.now(UTC) now = datetime.now(UTC)
# extract the ID token now to validate its authenticity and properly set the cookie lifetime # extract the ID token now to validate its authenticity and properly set the cookie lifetime
@ -56,17 +72,53 @@ def persist_auth_state(oidc_client: OpenidClient, resp: Response, tokens: TokenS
id_max_age = datetime.fromtimestamp(id_token.exp, UTC) - now id_max_age = datetime.fromtimestamp(id_token.exp, UTC) - now
nonce_max_age = max(at_max_age, id_max_age) nonce_max_age = max(at_max_age, id_max_age)
if tokens.refresh_token is not None and tokens.refresh_expires_in is not None: if tokens.refresh_token is not None and tokens.refresh_expires_in is not None:
rt_max_age = auth_start_time - now + timedelta(seconds=tokens.refresh_expires_in) rt_max_age = (
auth_start_time - now + timedelta(seconds=tokens.refresh_expires_in)
)
nonce_max_age = max(at_max_age, rt_max_age, id_max_age) nonce_max_age = max(at_max_age, rt_max_age, id_max_age)
if token_nonce is None: if token_nonce is None:
nonce_max_age = timedelta(0) nonce_max_age = timedelta(0)
# update cookies # update cookies
resp.set_cookie("access_token", tokens.access_token, max_age=int(at_max_age.total_seconds()), httponly=True, secure=True) resp.set_cookie(
"access_token",
tokens.access_token,
max_age=int(at_max_age.total_seconds()),
httponly=True,
secure=True,
)
if tokens.refresh_token is not None and tokens.refresh_expires_in is not None: if tokens.refresh_token is not None and tokens.refresh_expires_in is not None:
resp.set_cookie("refresh_token", tokens.refresh_token, max_age=int(rt_max_age.total_seconds()), httponly=True, secure=True) resp.set_cookie(
resp.set_cookie("id_token", tokens.id_token, max_age=int(id_max_age.total_seconds()), httponly=True, secure=True) "refresh_token",
resp.set_cookie("auth_nonce", token_nonce, max_age=int(nonce_max_age.total_seconds()), httponly=True, secure=True) tokens.refresh_token,
max_age=int(rt_max_age.total_seconds()),
httponly=True,
secure=True,
)
resp.set_cookie(
"id_token",
tokens.id_token,
max_age=int(id_max_age.total_seconds()),
httponly=True,
secure=True,
)
resp.set_cookie(
"auth_nonce",
token_nonce,
max_age=int(nonce_max_age.total_seconds()),
httponly=True,
secure=True,
)
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)
resp.set_cookie("auth_nonce", "", max_age=0)
resp.set_cookie("auth_next", "", max_age=0)
resp.set_cookie("auth_state", "", max_age=0)
resp.set_cookie("auth_start_time", "", max_age=0)
CurrentUser = Annotated[Optional[models.CurrentUser], Depends(get_current_user)] CurrentUser = Annotated[Optional[models.CurrentUser], Depends(get_current_user)]
@ -84,4 +136,3 @@ def get_ccujack(req: Request) -> CCUJackClient:
CCUJackClient = Annotated[CCUJackClient, Depends(get_ccujack)] CCUJackClient = Annotated[CCUJackClient, Depends(get_ccujack)]

View file

@ -1,4 +1,4 @@
from typing import Optional, Self, Mapping, Any, Literal from typing import Optional, Self, Literal
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, HttpUrl from pydantic import BaseModel, HttpUrl
from enum import Enum from enum import Enum
@ -11,6 +11,7 @@ class HttpProblemType(Enum):
""" """
Statically known HTTP problem types using the [type URI scheme](https://datatracker.ietf.org/doc/rfc4151/) Statically known HTTP problem types using the [type URI scheme](https://datatracker.ietf.org/doc/rfc4151/)
""" """
UNAUTHORIZED = "type:noc@hamburg.ccc.de,2026:UNAUTHORIZED" UNAUTHORIZED = "type:noc@hamburg.ccc.de,2026:UNAUTHORIZED"
DOOR_NOT_FOUND = "type:noc@hamburg.ccc.de,2026:DOOR_NOT_FOUND" DOOR_NOT_FOUND = "type:noc@hamburg.ccc.de,2026:DOOR_NOT_FOUND"
@ -19,6 +20,7 @@ class HttpProblemDetail(BaseModel):
""" """
API Error modeled after [RFC9475](https://www.rfc-editor.org/rfc/rfc9457.html). API Error modeled after [RFC9475](https://www.rfc-editor.org/rfc/rfc9457.html).
""" """
status: int status: int
type: HttpProblemType type: HttpProblemType
title: str title: str
@ -27,15 +29,28 @@ class HttpProblemDetail(BaseModel):
@classmethod @classmethod
def new_unauthorized(cls, request_uri: str | URL) -> Self: def new_unauthorized(cls, request_uri: str | URL) -> Self:
return cls(type=HttpProblemType.UNAUTHORIZED, status=status.HTTP_401_UNAUTHORIZED, title="Unauthorized", detail="You tried to access a ressource which requires authentication but you are not authenticated", instance=HttpUrl(str(request_uri))) return cls(
type=HttpProblemType.UNAUTHORIZED,
status=status.HTTP_401_UNAUTHORIZED,
title="Unauthorized",
detail="You tried to access a ressource which requires authentication but you are not authenticated",
instance=HttpUrl(str(request_uri)),
)
@classmethod @classmethod
def new_door_not_found(cls, requested_door: str, request_uri: str | URL) -> Self: def new_door_not_found(cls, requested_door: str, request_uri: str | URL) -> Self:
return cls(type=HttpProblemType.DOOR_NOT_FOUND, status=status.HTTP_404_NOT_FOUND, title="Door not found", detail=f"You tried to interact with door {requested_door!r} that is not known to dooris", instance=str(request_uri)) return cls(
type=HttpProblemType.DOOR_NOT_FOUND,
status=status.HTTP_404_NOT_FOUND,
title="Door not found",
detail=f"You tried to interact with door {requested_door!r} that is not known to dooris",
instance=str(request_uri),
)
class CurrentUser(BaseModel): class CurrentUser(BaseModel):
id_token: IdToken id_token: IdToken
raw_id_token: str
class UserStatus(BaseModel): class UserStatus(BaseModel):
@ -57,4 +72,3 @@ class LockStatus(BaseModel):
class Lock(BaseModel): class Lock(BaseModel):
name: str name: str
status: LockStatus status: LockStatus