2026-08-21 15:48:43 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
2026-10-12 20:00:17 +02:00
|
|
|
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,
|
|
|
|
|
)
|
2026-08-21 15:48:43 +02:00
|
|
|
from auth import create_session, hash_password, require_auth, verify_password
|
2026-10-12 20:00:17 +02:00
|
|
|
from db import get_db
|
|
|
|
|
from mailer import MailNotConfigured
|
2026-08-21 15:48:43 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-10-12 20:00:17 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-21 15:48:43 +02:00
|
|
|
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")
|
2026-10-12 20:00:17 +02:00
|
|
|
email = normalize_email(req.email)
|
2026-08-21 15:48:43 +02:00
|
|
|
profile_id = str(uuid.uuid4())
|
2026-10-12 20:00:17 +02:00
|
|
|
jobs: list[tuple[list[str], str, str]] = []
|
2026-08-21 15:48:43 +02:00
|
|
|
with get_db() as conn:
|
|
|
|
|
conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO profiles (id, email, name, password_hash, role)
|
|
|
|
|
VALUES (?, ?, ?, ?, 'admin')
|
|
|
|
|
""",
|
2026-10-12 20:00:17 +02:00
|
|
|
(profile_id, email, req.name.strip(), hash_password(req.password)),
|
2026-08-21 15:48:43 +02:00
|
|
|
)
|
2026-10-12 20:00:17 +02:00
|
|
|
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)
|
2026-08-21 15:48:43 +02:00
|
|
|
token, expires = create_session(profile_id)
|
|
|
|
|
return {
|
|
|
|
|
"token": token,
|
|
|
|
|
"profile_id": profile_id,
|
|
|
|
|
"name": req.name.strip(),
|
|
|
|
|
"role": "admin",
|
|
|
|
|
"expires_at": expires.isoformat(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-10-12 20:00:17 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-21 15:48:43 +02:00
|
|
|
@router.post("/login")
|
|
|
|
|
def login(req: LoginRequest):
|
|
|
|
|
with get_db() as conn:
|
2026-10-12 20:00:17 +02:00
|
|
|
prof = find_by_login(conn, req.email)
|
2026-08-21 15:48:43 +02:00
|
|
|
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:
|
2026-10-12 20:00:17 +02:00
|
|
|
prof = profile_view(load_profile(conn, session["profile_id"]))
|
2026-08-21 15:48:43 +02:00
|
|
|
if not prof:
|
|
|
|
|
raise HTTPException(401, "Profil nicht gefunden")
|
|
|
|
|
return prof
|
2026-10-12 20:00:17 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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}
|