Kansho/backend/routers/users.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

238 lines
8.5 KiB
Python

from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from account_access import (
admin_reset_recipients,
apply_identifier_edits,
assert_address_free,
assert_username_free,
deliver_jobs,
issue_password_reset,
issue_verification,
load_profile,
normalize_email,
normalize_username,
profile_view,
reset_message,
verify_message,
)
from auth import hash_password, require_admin_dep, require_auth
from db import get_db
from mailer import MailNotConfigured
router = APIRouter(prefix="/api/users", tags=["users"])
class CreateUserRequest(BaseModel):
email: str
name: str = Field(min_length=1)
password: str = Field(min_length=4)
role: str = "user"
username: str | None = None
class PatchUserRequest(BaseModel):
name: str | None = None
role: str | None = None
status: str | None = None
password: str | None = None
username: str | None = None
email: str | None = None
secondary_email: str | None = None
class SelfPatchRequest(BaseModel):
username: str | None = None
email: str | None = None
secondary_email: str | None = None
password: str | None = None
def _active_admin_count(conn, exclude_id: str | None = None) -> int:
if exclude_id:
row = conn.execute(
"SELECT COUNT(*) AS n FROM profiles WHERE role = 'admin' AND status = 'active' AND id != ?",
(exclude_id,),
).fetchone()
else:
row = conn.execute(
"SELECT COUNT(*) AS n FROM profiles WHERE role = 'admin' AND status = 'active'"
).fetchone()
return int(row["n"])
def _send_jobs(jobs: list[tuple[list[str], str, str]]) -> bool:
if not jobs:
return True
try:
deliver_jobs(jobs)
except MailNotConfigured:
return False
return True
@router.get("")
def list_users(session: dict = Depends(require_admin_dep)):
with get_db() as conn:
rows = conn.execute("SELECT * FROM profiles ORDER BY created").fetchall()
return [profile_view(dict(row)) for row in rows]
@router.post("")
def create_user(req: CreateUserRequest, session: dict = Depends(require_admin_dep)):
if req.role not in {"user", "admin"}:
raise HTTPException(400, "Rolle muss user oder admin sein")
email = normalize_email(req.email)
username = normalize_username(req.username) if req.username and req.username.strip() else None
profile_id = str(uuid.uuid4())
jobs: list[tuple[list[str], str, str]] = []
try:
with get_db() as conn:
assert_address_free(conn, email, None)
if username:
assert_username_free(conn, username, None)
conn.execute(
"""
INSERT INTO profiles (id, email, name, password_hash, role, status, tier_id, username)
VALUES (?, ?, ?, ?, ?, 'active', 'local', ?)
""",
(
profile_id,
email,
req.name.strip(),
hash_password(req.password),
req.role,
username,
),
)
raw = issue_verification(conn, profile_id, "primary", email)
if raw:
subject, body = verify_message(raw)
jobs.append(([email], subject, body))
prof = profile_view(load_profile(conn, profile_id))
except HTTPException:
raise
except Exception as exc:
if "UNIQUE" in str(exc).upper():
raise HTTPException(409, "E-Mail ist bereits vergeben") from exc
raise
if not _send_jobs(jobs):
prof = dict(prof or {})
prof["mail_sent"] = False
return prof
@router.patch("/me")
def patch_me(req: SelfPatchRequest, session: dict = Depends(require_auth)):
provided = req.model_fields_set
jobs: list[tuple[list[str], str, str]] = []
with get_db() as conn:
current = load_profile(conn, session["profile_id"])
if not current:
raise HTTPException(401, "Profil nicht gefunden")
if req.password:
if len(req.password) < 4:
raise HTTPException(400, "Passwort muss mind. 4 Zeichen haben")
conn.execute(
"UPDATE profiles SET password_hash = ? WHERE id = ?",
(hash_password(req.password), current["id"]),
)
jobs = apply_identifier_edits(
conn,
current,
username=req.username,
email=req.email,
secondary_email=req.secondary_email,
username_set="username" in provided,
email_set="email" in provided,
secondary_set="secondary_email" in provided,
)
prof = profile_view(load_profile(conn, current["id"]))
if not _send_jobs(jobs):
raise HTTPException(503, "Die Adresse ist vorgemerkt, der Mailversand ist nicht konfiguriert.")
return prof
@router.patch("/{profile_id}")
def patch_user(profile_id: str, req: PatchUserRequest, session: dict = Depends(require_admin_dep)):
provided = req.model_fields_set
jobs: list[tuple[list[str], str, str]] = []
with get_db() as conn:
current = load_profile(conn, profile_id)
if not current:
raise HTTPException(404, "Nutzer nicht gefunden")
name = req.name.strip() if req.name is not None else current["name"]
role = req.role if req.role is not None else current["role"]
status = req.status if req.status is not None else current["status"]
if role not in {"user", "admin"}:
raise HTTPException(400, "Rolle muss user oder admin sein")
if status not in {"active", "disabled"}:
raise HTTPException(400, "Status muss active oder disabled sein")
becomes_non_admin = current["role"] == "admin" and (role != "admin" or status == "disabled")
if becomes_non_admin and _active_admin_count(conn, exclude_id=profile_id) < 1:
raise HTTPException(400, "Der letzte aktive Admin kann nicht entfernt werden")
password_hash = current["password_hash"]
if req.password:
if len(req.password) < 4:
raise HTTPException(400, "Passwort muss mind. 4 Zeichen haben")
password_hash = hash_password(req.password)
conn.execute(
"""
UPDATE profiles SET name = ?, role = ?, status = ?, password_hash = ?
WHERE id = ?
""",
(name, role, status, password_hash, profile_id),
)
if status == "disabled":
conn.execute("DELETE FROM sessions WHERE profile_id = ?", (profile_id,))
current = load_profile(conn, profile_id) or current
jobs = apply_identifier_edits(
conn,
current,
username=req.username,
email=req.email,
secondary_email=req.secondary_email,
username_set="username" in provided,
email_set="email" in provided,
secondary_set="secondary_email" in provided,
)
prof = profile_view(load_profile(conn, profile_id))
if not _send_jobs(jobs):
raise HTTPException(503, "Die Adresse ist vorgemerkt, der Mailversand ist nicht konfiguriert.")
return prof
@router.post("/{profile_id}/password-reset")
def admin_password_reset(profile_id: str, session: dict = Depends(require_admin_dep)):
if profile_id == session["profile_id"]:
raise HTTPException(400, "Den eigenen Reset startest du über die Anmeldung")
jobs: list[tuple[list[str], str, str]] = []
with get_db() as conn:
profile = load_profile(conn, profile_id)
if not profile:
raise HTTPException(404, "Nutzer nicht gefunden")
recipients = admin_reset_recipients(profile)
if not recipients:
raise HTTPException(400, "Für dieses Konto ist keine E-Mail hinterlegt")
raw = issue_password_reset(conn, profile_id)
if not raw:
raise HTTPException(429, "Zu viele Reset-Links. Bitte später erneut versuchen.")
subject, body = reset_message(raw)
jobs.append((recipients, subject, body))
if not _send_jobs(jobs):
raise HTTPException(503, "Mailversand ist nicht konfiguriert")
return {"ok": True}
@router.get("/me")
def my_account(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