"""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, "default_profile": u.default_profile or "meeting", # Leerer String = User hat keine Modell-Wahl getroffen → Mac-Worker # nimmt seinen .env-Default. UI zeigt das als „Standard (Worker)". "default_model": u.default_model or "", } class UpdateMeIn(BaseModel): default_profile: str | None = Field(default=None, max_length=64) # Leerer String bedeutet bewusst „kein Override" — wird gespeichert. default_model: str | None = Field(default=None, max_length=128) @router.get("/me") def me(user: User = Depends(current_user)): return _user_dict(user) @router.patch("/me") def update_me(payload: UpdateMeIn, user: User = Depends(current_user), session: Session = Depends(get_session)): changed = False if payload.default_profile is not None: user.default_profile = payload.default_profile.strip() or "meeting" changed = True if payload.default_model is not None: user.default_model = payload.default_model.strip() changed = True if changed: session.add(user) session.commit() session.refresh(user) return _user_dict(user) class ChangePasswordIn(BaseModel): current_password: str = Field(min_length=1, max_length=200) new_password: str = Field(min_length=8, max_length=200) @router.post("/me/password") def change_password( payload: ChangePasswordIn, user: User = Depends(current_user), session: Session = Depends(get_session), ): """User ändert eigenes Passwort. Aktuelles Passwort muss korrekt sein — verhindert Übernahme, wenn jemand eine offene Browser-Session findet. Admin-Reset läuft weiter über /api/admin/users//password (ohne current_password). """ if not verify_password(payload.current_password, user.password_hash): log.warning("Password-Change abgelehnt für user=%s (aktuelles Passwort falsch)", user.username) raise HTTPException(status.HTTP_400_BAD_REQUEST, "Aktuelles Passwort falsch") user.password_hash = hash_password(payload.new_password) session.add(user) session.commit() log.info("Password geändert für user=%s (id=%d)", user.username, user.id) return {"ok": True} @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)): username = payload.username.strip() user = get_user_by_username(session, username) if not user: log.warning("Login fehlgeschlagen: User '%s' nicht gefunden", username) raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch") if not verify_password(payload.password, user.password_hash): log.warning( "Login fehlgeschlagen: Passwort falsch für user=%s (hash_len=%d, prefix=%s)", user.username, len(user.password_hash), user.password_hash[:7], ) raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch") request.session["user_id"] = user.id log.info("Login OK: 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}