Files
ImpTune/imptune/api/session.py
T
kawaandClaude Haiku 4.5 ed41f7f520 feat(session): per-owner printer/config storage via cookie-scoped bearer key
Printers and groups (Client) are now scoped to an Owner identified by an opaque
bearer key (secrets.token_urlsafe(32)) stored in an httponly cookie, defaulting
to temporary. First-visit modal offers backup-key download (marks permanent) or
temporary-only choice. /session/restore re-attaches a fresh browser to a saved
key. Every printer-facing route enforces ownership (404 on mismatch, not just
filtering) since printer IDs are sequential ints. Drivers stay global/shared.

On upgrade, pre-existing printer/client rows backfill to a synthetic legacy Owner;
its key is written to {DATA_DIR}/legacy_owner_key.txt for manual restore.

SECURITY: Added Origin/Referer same-origin check on POST /session/restore to
block login-CSRF/session-fixation attacks (cross-site form POST can't re-point
victim's cookie at attacker's Owner without hitting that check first).

Tests: 140 pass (2 deselected: pre-existing locale-flaky, unrelated to this change).
Verified live: modal on first visit, isolation between browsers, backup-key
download and restore flow work end-to-end.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-04 11:29:43 +02:00

74 lines
2.3 KiB
Python

"""Owner session routes — backup-key download and restore-on-new-browser."""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
import imptune.config as cfg
from imptune.db.models import Owner
from imptune.services.session import COOKIE_MAX_AGE, COOKIE_NAME, is_same_origin
router = APIRouter(prefix="/session")
templates = Jinja2Templates(
directory=str(Path(__file__).parent.parent / "templates")
)
@router.get("/key/download")
def download_key(request: Request) -> PlainTextResponse:
"""Mark the current owner permanent and hand back its key as a backup file."""
owner: Owner = request.state.owner
if not owner.is_permanent:
owner.is_permanent = True
owner.save()
return PlainTextResponse(
content=owner.key,
headers={"Content-Disposition": 'attachment; filename="imptune-backup-key.txt"'},
)
@router.get("/restore", response_class=HTMLResponse)
def restore_page(request: Request, error: str = "") -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="session_restore.html",
context={"error": error},
)
@router.post("/restore")
def restore_session(request: Request, key: str = Form(...)):
"""Re-associate this browser with a previously downloaded backup key."""
if not is_same_origin(request):
return templates.TemplateResponse(
request=request,
name="session_restore.html",
context={"error": "Request rejected — please submit this form directly from this site."},
status_code=403,
)
owner = Owner.get_or_none(Owner.key == key.strip())
if owner is None:
return templates.TemplateResponse(
request=request,
name="session_restore.html",
context={"error": "Key not found."},
status_code=404,
)
response = RedirectResponse(url="/", status_code=303)
response.set_cookie(
COOKIE_NAME,
owner.key,
max_age=COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=cfg.COOKIE_SECURE,
)
return response