Kansho/backend/routers/auth.py
Lars 89d149684e
Some checks failed
Deploy Development / deploy (push) Successful in 58s
Test Suite / pytest-backend (push) Failing after 3m11s
Test Suite / smoke-dev (push) Successful in 0s
Test Suite / frontend-build (push) Successful in 16s
Add username login and expiring reset links so accounts stay recoverable.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 20:00:17 +02:00

190 lines
5.3 KiB
Python

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}