Compare commits
No commits in common. "745dfaf19f2c31c9cb401685ed27519942f76dce" and "51349c297bde2b4bff678f1d880b994923aa61c7" have entirely different histories.
745dfaf19f
...
51349c297b
6 changed files with 16 additions and 119 deletions
|
|
@ -4,7 +4,6 @@ version = "0.1.0"
|
|||
description = "API for Dooris setup using HomeMatic."
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"cachetools>=7.1.1",
|
||||
"fastapi>=0.136.1",
|
||||
"simple-openid-connect>=2.4.0",
|
||||
"uvicorn>=0.46.0",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
from typing import Optional, List
|
||||
from typing import Optional
|
||||
import logging
|
||||
import secrets
|
||||
import sys
|
||||
from datetime import datetime, UTC
|
||||
from fastapi import FastAPI, Request, Response, HTTPException, status
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from contextlib import asynccontextmanager
|
||||
from simple_openid_connect.client import OpenidClient
|
||||
from simple_openid_connect.data import TokenSuccessResponse
|
||||
from cachetools import TTLCache
|
||||
|
||||
from dooris_api import deps, models, exceptions
|
||||
from dooris_api import deps
|
||||
from dooris_api.models import UserStatus, UserInfo
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -30,9 +30,6 @@ async def lifespan(app: FastAPI):
|
|||
client_id="dooris",
|
||||
client_secret="dp9HhnvUhAtKm3pRnxfGA7q8Nwrd1td8",
|
||||
)
|
||||
|
||||
app.extra["cache"] = TTLCache(maxsize=64, ttl=30 * 60)
|
||||
|
||||
yield
|
||||
|
||||
|
||||
|
|
@ -40,24 +37,23 @@ app = FastAPI(
|
|||
title="Dooris", summary="API for interacting with HomeMatic door locks in CCCHH", 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"], responses={status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail}})
|
||||
async def get_user_info(req: Request, current_user: deps.CurrentUser) -> models.UserStatus:
|
||||
@app.get("/api/user-info/", name="get-user-info")
|
||||
async def get_user_info(req: Request, current_user: deps.CurrentUser) -> UserStatus:
|
||||
if current_user is None:
|
||||
return models.UserStatus(is_logged_in=False, user_info=None, guaranteed_session_until=None)
|
||||
return UserStatus(is_logged_in=False, user_info=None)
|
||||
else:
|
||||
return models.UserStatus(
|
||||
return UserStatus(
|
||||
is_logged_in=True,
|
||||
guaranteed_session_until=datetime.fromtimestamp(current_user.id_token.exp, UTC),
|
||||
user_info=models.UserInfo(
|
||||
user_info=UserInfo(
|
||||
username=current_user.id_token.preferred_username,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.get("/auth/login", tags=["auth"], response_class=RedirectResponse, status_code=302)
|
||||
@app.get("/auth/login", 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")
|
||||
|
||||
|
|
@ -81,7 +77,7 @@ async def login_init(req: Request, resp: Response, oidc_client: deps.OpenidClien
|
|||
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", response_class=RedirectResponse, status_code=302)
|
||||
async def login_callback(req: Request, resp: Response, oidc_client: deps.OpenidClient):
|
||||
# check that the user is currently in an authenticating state
|
||||
# these cookies are set by the login_init() view
|
||||
|
|
@ -110,15 +106,4 @@ async def login_callback(req: Request, resp: Response, oidc_client: deps.OpenidC
|
|||
return f"/auth/login-error?{req.query_params}"
|
||||
|
||||
|
||||
@app.get("/api/doors/", tags=["doors"], responses={status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail}})
|
||||
async def list_doors(cache: deps.Cache, _user: deps.CurrentUser) -> List[models.Door]:
|
||||
return [
|
||||
models.Door(name="door1", description="A static door for testing", status="unknown"),
|
||||
models.Door(name="door2", description="another static door for testing", status="unknown"),
|
||||
]
|
||||
|
||||
|
||||
@app.get("/api/doors/{name}", tags=["doors"], responses={status.HTTP_404_NOT_FOUND: {"model": models.HttpProblemDetail}, status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail}})
|
||||
async def get_door(name: str, req: Request, cache: deps.Cache, _user: deps.CurrentUser) -> Optional[models.Door]:
|
||||
raise exceptions.HttpProblemException(models.HttpProblemDetail.new_door_not_found(name, req.url))
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ from datetime import datetime, UTC, timedelta
|
|||
from fastapi import Request, Depends, Response
|
||||
from simple_openid_connect.data import TokenSuccessResponse
|
||||
from simple_openid_connect.client import OpenidClient
|
||||
from cachetools import Cache
|
||||
|
||||
from dooris_api import models, exceptions
|
||||
from dooris_api import models
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -41,7 +40,7 @@ async def get_current_user(req: Request, resp: Response, oidc_client: OpenidClie
|
|||
|
||||
# otherwise we can't meaningfully recover any user information or the user is simply not authenticated
|
||||
logger.debug("no currently authenticated user")
|
||||
raise exceptions.HttpProblemException(models.HttpProblemDetail.new_unauthorized(req.url))
|
||||
return None
|
||||
|
||||
|
||||
def persist_auth_state(oidc_client: OpenidClient, resp: Response, tokens: TokenSuccessResponse, auth_start_time: datetime, token_nonce: Optional[str] = None):
|
||||
|
|
@ -70,10 +69,3 @@ def persist_auth_state(oidc_client: OpenidClient, resp: Response, tokens: TokenS
|
|||
|
||||
CurrentUser = Annotated[Optional[models.CurrentUser], Depends(get_current_user)]
|
||||
|
||||
|
||||
def get_cache(req: Request) -> Cache:
|
||||
return req.app.extra["cache"]
|
||||
|
||||
|
||||
Cache = Annotated[Cache, Depends(get_cache)]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
from typing import Mapping, Optional
|
||||
from fastapi import HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.utils import is_body_allowed_for_status_code
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
from dooris_api import models
|
||||
|
||||
|
||||
class HttpProblemException(HTTPException):
|
||||
problem: models.HttpProblemDetail
|
||||
headers: Optional[Mapping[str, str]]
|
||||
|
||||
def __init__(self, problem: models.HttpProblemDetail, headers: Optional[Mapping[str, str]] = None):
|
||||
self.problem = problem
|
||||
super().__init__(status_code=problem.status, headers=headers)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.problem)
|
||||
|
||||
|
||||
async def problem_exception_handler(request: Request, exc: HttpProblemException) -> Response:
|
||||
headers = exc.headers
|
||||
if not is_body_allowed_for_status_code(exc.problem.status):
|
||||
return Response(status_code=exc.status_code, headers=headers)
|
||||
return JSONResponse(jsonable_encoder(exc.problem), status_code=exc.status_code, headers=exc.headers)
|
||||
|
|
@ -1,37 +1,7 @@
|
|||
from typing import Optional, Self
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel
|
||||
from simple_openid_connect.data import IdToken
|
||||
from starlette.datastructures import URL
|
||||
from fastapi import status
|
||||
|
||||
|
||||
class HttpProblemType(Enum):
|
||||
"""
|
||||
Statically known HTTP problem types using the [type URI scheme](https://datatracker.ietf.org/doc/rfc4151/)
|
||||
"""
|
||||
UNAUTHORIZED = "type:noc@hamburg.ccc.de,2026:UNAUTHORIZED"
|
||||
DOOR_NOT_FOUND = "type:noc@hamburg.ccc.de,2026:DOOR_NOT_FOUND"
|
||||
|
||||
|
||||
class HttpProblemDetail(BaseModel):
|
||||
"""
|
||||
API Error modeled after [RFC9475](https://www.rfc-editor.org/rfc/rfc9457.html).
|
||||
"""
|
||||
status: int
|
||||
type: HttpProblemType
|
||||
title: str
|
||||
detail: str
|
||||
instance: Optional[HttpUrl]
|
||||
|
||||
@classmethod
|
||||
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)))
|
||||
|
||||
@classmethod
|
||||
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))
|
||||
|
||||
|
||||
class CurrentUser(BaseModel):
|
||||
|
|
@ -44,18 +14,6 @@ class UserInfo(BaseModel):
|
|||
|
||||
class UserStatus(BaseModel):
|
||||
is_logged_in: bool
|
||||
guaranteed_session_until: Optional[datetime]
|
||||
guaranteed_session_until: datetime
|
||||
user_info: Optional[UserInfo]
|
||||
|
||||
|
||||
class DoorStatus(Enum):
|
||||
OPEN = "open"
|
||||
CLOSED = "closed"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class Door(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
status: DoorStatus
|
||||
|
||||
|
|
|
|||
11
api/uv.lock
generated
11
api/uv.lock
generated
|
|
@ -41,15 +41,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
version = "7.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/e2/85f227594656000ff4d8adadae91a21f536d4a84c6c716a86bd6685874be/cachetools-7.1.1.tar.gz", hash = "sha256:27bdf856d68fd3c71c26c01b5edc312124ed427524d1ddb31aa2b7746fe20d4b", size = 40202, upload-time = "2026-05-03T20:00:29.391Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/0f/f897abe4ea0a8c408ae65c8c83bffab4936ad65d6032d4fb4cd35bbdc3ee/cachetools-7.1.1-py3-none-any.whl", hash = "sha256:0335cd7a0952d2b22327441fb0628139e234c565559eeb91a8a4ac7551c5353d", size = 16775, upload-time = "2026-05-03T20:00:27.857Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.4.22"
|
||||
|
|
@ -262,7 +253,6 @@ name = "dooris-api"
|
|||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "cachetools" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "simple-openid-connect" },
|
||||
{ name = "uvicorn" },
|
||||
|
|
@ -275,7 +265,6 @@ dev = [
|
|||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "cachetools", specifier = ">=7.1.1" },
|
||||
{ name = "fastapi", specifier = ">=0.136.1" },
|
||||
{ name = "simple-openid-connect", specifier = ">=2.4.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.46.0" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue