root a1167df4f1 feat: User-Verwaltung mit Login, Sessions und Owner-Filter
Backend:
- User-Modell (sqlmodel) mit bcrypt-Passwort-Hash und is_admin-Flag
- Job.owner_id (FK auf user.id), per Migration nachgerüstet (SQLite ALTER)
- Starlette SessionMiddleware mit signiertem Cookie (SESSION_SECRET)
- /api/auth: login / logout / me / needs-setup / setup (Initial-Admin)
- /api/admin: User-CRUD + Passwort-Reset (Admin-only); letzter Admin /
  eigener Account vor Löschung geschützt
- /api/jobs: Owner-Filter (Admin sieht alle inkl. owner_username),
  404 statt 403 bei fremden Jobs (kein Existenz-Leak)

Frontend:
- Auth-Overlay mit Login bzw. Erst-Setup-Modus (auto-Detection)
- Header zeigt User + Logout, Admin-Badge bei Admin-Konten
- Admin-Karte: User anlegen, Passwort zurücksetzen, Löschen (mit Confirm)
- 401 → Login-Overlay (auch bei XHR-Upload)

Deploy:
- install.sh generiert SESSION_SECRET einmalig per python-secrets
- .env wird auf chmod 600 gesetzt
- Erstaufruf zeigt automatisch das Setup-Formular; bestehende Jobs werden
  dem ersten Admin zugeordnet
2026-05-14 02:38:52 +00:00

84 lines
2.6 KiB
Python

"""Auth-Endpoints: Login, Logout, Me, Erst-Setup (Initial-Admin)."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlmodel import Session
from app.core import migrate
from app.core.auth import (
current_user,
get_user_by_username,
has_any_user,
hash_password,
verify_password,
)
from app.core.db import get_session
from app.models.user import User
router = APIRouter(prefix="/api/auth", tags=["auth"])
log = logging.getLogger("voice-agent.auth")
class LoginIn(BaseModel):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=1, max_length=200)
class SetupIn(BaseModel):
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9._@-]+$")
password: str = Field(min_length=8, max_length=200)
def _user_dict(u: User) -> dict:
return {"id": u.id, "username": u.username, "is_admin": u.is_admin}
@router.get("/me")
def me(user: User = Depends(current_user)):
return _user_dict(user)
@router.get("/needs-setup")
def needs_setup(session: Session = Depends(get_session)):
"""Vor Login prüfen ob noch kein User existiert (erstes Deployment)."""
return {"needs_setup": not has_any_user(session)}
@router.post("/login")
def login(payload: LoginIn, request: Request, session: Session = Depends(get_session)):
user = get_user_by_username(session, payload.username.strip())
if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch")
request.session["user_id"] = user.id
log.info("Login: user=%s id=%d admin=%s", user.username, user.id, user.is_admin)
return _user_dict(user)
@router.post("/logout")
def logout(request: Request):
request.session.clear()
return {"ok": True}
@router.post("/setup")
def setup(payload: SetupIn, request: Request, session: Session = Depends(get_session)):
"""Erstellt den ersten Admin und ordnet alle Bestand-Jobs ihm zu.
Nur möglich, solange kein User existiert."""
if has_any_user(session):
raise HTTPException(status.HTTP_409_CONFLICT, "Setup bereits abgeschlossen")
user = User(
username=payload.username.strip(),
password_hash=hash_password(payload.password),
is_admin=True,
)
session.add(user)
session.commit()
session.refresh(user)
log.info("Initial-Admin angelegt: %s (id=%d)", user.username, user.id)
n = migrate.assign_orphan_jobs_to(user.id)
request.session["user_id"] = user.id
return {**_user_dict(user), "migrated_jobs": n}