fix(auth): protect setup and expire sessions

This commit is contained in:
2026-07-27 20:48:03 +02:00
parent e7607a0f3c
commit a7e68e2eab
4 changed files with 97 additions and 16 deletions
+31 -5
View File
@@ -2,7 +2,7 @@ import asyncio
import base64
import hashlib
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Annotated, Any, cast
@@ -57,6 +57,7 @@ def problem_response(request: Request, status: int, code: str, detail: str) -> J
class SetupInput(BaseModel):
username: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=12, max_length=1024)
bootstrap_secret: str | None = Field(default=None, max_length=1024)
class LoginInput(BaseModel):
@@ -127,6 +128,7 @@ def create_app(settings: Settings) -> FastAPI:
app.state.sessions = async_sessionmaker(app.state.engine, expire_on_commit=False)
app.state.cipher = EnvelopeCipher.from_file(settings.master_key_file)
app.state.setup_lock = asyncio.Lock()
app.state.revoked_sessions = set()
@app.middleware("http")
async def request_id_middleware(request: Request, call_next: Any) -> Response:
@@ -167,7 +169,7 @@ def create_app(settings: Settings) -> FastAPI:
return user, set(token.scopes), False
encoded = request.cookies.get("backup_tool_session")
data = verify_session(encoded, settings.master_key_file) if encoded else None
if data is None:
if data is None or data["sid"] in app.state.revoked_sessions:
raise Problem(401, "authentication_required", "Authentication is required.")
user = await db.get(User, data["sub"])
if user is None or user.state != "active":
@@ -213,16 +215,29 @@ def create_app(settings: Settings) -> FastAPI:
def set_session(response: Response, user_id: str) -> None:
csrf = new_csrf_token()
ttl = settings.session_ttl_seconds
response.set_cookie(
"backup_tool_session",
sign_session(user_id, csrf, settings.master_key_file),
sign_session(
user_id,
csrf,
settings.master_key_file,
expires_at=datetime.now(UTC) + timedelta(seconds=ttl),
),
httponly=True,
secure=True,
samesite="strict",
path="/",
max_age=ttl,
)
response.set_cookie(
"backup_tool_csrf", csrf, httponly=False, secure=True, samesite="strict", path="/"
"backup_tool_csrf",
csrf,
httponly=False,
secure=True,
samesite="strict",
path="/",
max_age=ttl,
)
@app.get("/livez")
@@ -247,6 +262,11 @@ def create_app(settings: Settings) -> FastAPI:
db: Annotated[AsyncSession, Depends(session)],
) -> dict[str, str]:
async with app.state.setup_lock:
if (
settings.bootstrap_secret is not None
and input_.bootstrap_secret != settings.bootstrap_secret
):
raise Problem(403, "bootstrap_required", "Bootstrap credentials are required.")
if await db.scalar(select(User.id).limit(1)) is not None:
raise Problem(409, "setup_complete", "Initial administrator already exists.")
user = User(username=input_.username, password_hash=hash_password(input_.password))
@@ -286,8 +306,14 @@ def create_app(settings: Settings) -> FastAPI:
@app.post("/api/v2/auth/logout", status_code=204)
async def logout(
response: Response, _: Annotated[tuple[User, set[str], bool], Depends(require)]
request: Request,
response: Response,
_: Annotated[tuple[User, set[str], bool], Depends(require)],
) -> None:
encoded = request.cookies.get("backup_tool_session")
data = verify_session(encoded, settings.master_key_file) if encoded else None
if data is not None:
app.state.revoked_sessions.add(data["sid"])
response.delete_cookie("backup_tool_session", path="/")
response.delete_cookie("backup_tool_csrf", path="/")