from __future__ import annotations import uuid from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from account_access import ( FORGOT_MESSAGE, consume_password_reset, consume_verification, deliver_jobs, find_by_login, issue_password_reset, issue_verification, load_profile, normalize_email, profile_view, resend_verification, reset_message, verified_reset_recipients, verify_message, ) from auth import create_session, hash_password, require_auth, verify_password from db import get_db from mailer import MailNotConfigured from version import APP_VERSION router = APIRouter(prefix="/api/auth", tags=["auth"]) class LoginRequest(BaseModel): email: str password: str = Field(min_length=4) class SetupRequest(BaseModel): email: str name: str = Field(min_length=1) password: str = Field(min_length=4) class ForgotRequest(BaseModel): email: str = Field(min_length=1) class ResetPasswordRequest(BaseModel): token: str = Field(min_length=10) password: str = Field(min_length=4) class VerifyEmailRequest(BaseModel): token: str = Field(min_length=10) class ResendVerificationRequest(BaseModel): purpose: str def _profile_count() -> int: with get_db() as conn: row = conn.execute("SELECT COUNT(*) AS n FROM profiles").fetchone() return int(row["n"]) @router.get("/status") def auth_status(): return { "status": "ok", "service": "kansho", "version": APP_VERSION, "needs_setup": _profile_count() == 0, } @router.post("/setup") def setup(req: SetupRequest): if _profile_count() > 0: raise HTTPException(409, "Setup ist bereits abgeschlossen") email = normalize_email(req.email) profile_id = str(uuid.uuid4()) jobs: list[tuple[list[str], str, str]] = [] with get_db() as conn: conn.execute( """ INSERT INTO profiles (id, email, name, password_hash, role) VALUES (?, ?, ?, ?, 'admin') """, (profile_id, email, req.name.strip(), hash_password(req.password)), ) raw = issue_verification(conn, profile_id, "primary", email) if raw: subject, body = verify_message(raw) jobs.append(([email], subject, body)) _send_or_log_failure(jobs) token, expires = create_session(profile_id) return { "token": token, "profile_id": profile_id, "name": req.name.strip(), "role": "admin", "expires_at": expires.isoformat(), } def _send_or_log_failure(jobs: list[tuple[list[str], str, str]]) -> bool: if not jobs: return True try: deliver_jobs(jobs) except MailNotConfigured: return False return True @router.post("/login") def login(req: LoginRequest): with get_db() as conn: prof = find_by_login(conn, req.email) if not prof or not verify_password(req.password, prof["password_hash"]): raise HTTPException(401, "Ungültige Zugangsdaten") if (prof.get("status") or "active") != "active": raise HTTPException(401, "Ungültige Zugangsdaten") token, expires = create_session(prof["id"], prof.get("session_days") or 30) return { "token": token, "profile_id": prof["id"], "name": prof["name"], "role": prof["role"], "expires_at": expires.isoformat(), } @router.post("/logout") def logout(session: dict = Depends(require_auth)): with get_db() as conn: conn.execute("DELETE FROM sessions WHERE token = ?", (session["token"],)) return {"ok": True} @router.get("/me") def me(session: dict = Depends(require_auth)): with get_db() as conn: prof = profile_view(load_profile(conn, session["profile_id"])) if not prof: raise HTTPException(401, "Profil nicht gefunden") return prof @router.post("/forgot-password") def forgot_password(req: ForgotRequest): jobs: list[tuple[list[str], str, str]] = [] with get_db() as conn: prof = find_by_login(conn, req.email) if prof and (prof.get("status") or "active") == "active": recipients = verified_reset_recipients(prof) if recipients: raw = issue_password_reset(conn, prof["id"]) if raw: subject, body = reset_message(raw) jobs.append((recipients, subject, body)) _send_or_log_failure(jobs) return {"ok": True, "message": FORGOT_MESSAGE} @router.post("/reset-password") def reset_password(req: ResetPasswordRequest): with get_db() as conn: consume_password_reset(conn, req.token, req.password) return {"ok": True} @router.post("/verify-email") def verify_email(req: VerifyEmailRequest): with get_db() as conn: return consume_verification(conn, req.token) @router.post("/resend-verification") def resend(req: ResendVerificationRequest, session: dict = Depends(require_auth)): with get_db() as conn: profile = load_profile(conn, session["profile_id"]) if not profile: raise HTTPException(401, "Profil nicht gefunden") recipients, subject, body = resend_verification(conn, profile, req.purpose) jobs = [(recipients, subject, body)] if not _send_or_log_failure(jobs): raise HTTPException(503, "Mailversand ist nicht konfiguriert") return {"ok": True}