kundenplattform/auth.py
Thomas Stallinger 9265d47916 fix: Kontolöschung schlug mit 500 fehl (bytes-vs-str, psycopg-Eigenart)
Live bei der Verifikation gefunden: konto_loeschen() lud das Zielkonto
über get_by_email(ziel['email']) nach - der email-Wert kam aus
fetch_benutzer_fuer_organisation()'s sync-Verbindungspfad, wo psycopg
TEXT-Spalten teils als bytes statt str liefert (dasselbe bereits bekannte,
nicht deterministische Verhalten wie in _row_to_user(), siehe dortiger
Kommentar) - die async get_by_email()-Abfrage bekam dadurch einen
bytes-Parameter und scheiterte mit 'operator does not exist: text =
bytea'.

Zwei Fixes: konto_loeschen() nutzt jetzt user_manager.get(id) statt des
E-Mail-Umwegs (nur async-Pfad, umgeht das Problem strukturell).
fetch_benutzer_fuer_organisation() bekommt zusätzlich denselben
defensiven bytes-Decode wie _row_to_user(), damit das nicht an anderer
Stelle erneut zuschlägt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 07:45:11 +02:00

283 lines
9.2 KiB
Python

import os
import uuid
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Optional
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, schemas
from fastapi_users.authentication import AuthenticationBackend, CookieTransport, JWTStrategy
from fastapi_users.db import BaseUserDatabase
from db import get_async_database_connection, get_database_connection
from templating import templates
SESSION_SECRET = os.environ.get("KUNDENPLATTFORM_SESSION_SECRET", "")
COOKIE_NAME = "kundenplattform_session"
COOKIE_MAX_AGE = 14 * 24 * 60 * 60 # 14 Tage, wie zuvor Starlettes SessionMiddleware-Default
BENUTZER_SPALTEN = (
"id, organization_id, email, hashed_password, is_active, is_superuser, "
"ist_organisationsadmin, is_verified"
)
@dataclass
class User:
id: uuid.UUID
email: str
hashed_password: str
organization_id: uuid.UUID
is_active: bool = True
is_superuser: bool = False
ist_organisationsadmin: bool = False
is_verified: bool = True
class UserCreate(schemas.BaseUserCreate):
organization_id: uuid.UUID
ist_organisationsadmin: Optional[bool] = False
is_verified: Optional[bool] = True
def _row_to_user(row) -> User:
(
user_id, organization_id, email, hashed_password, is_active,
is_superuser, ist_organisationsadmin, is_verified,
) = row
# psycopg liefert TEXT-Spalten hier teils als bytes zurück, nicht als str.
if isinstance(hashed_password, bytes):
hashed_password = hashed_password.decode("utf-8")
if isinstance(email, bytes):
email = email.decode("utf-8")
return User(
id=user_id,
organization_id=organization_id,
email=email,
hashed_password=hashed_password,
is_active=is_active,
is_superuser=is_superuser,
ist_organisationsadmin=ist_organisationsadmin,
is_verified=is_verified,
)
class UserDatabase(BaseUserDatabase[User, uuid.UUID]):
def __init__(self, connection):
self.connection = connection
async def get(self, id: uuid.UUID) -> Optional[User]:
async with self.connection.cursor() as cur:
await cur.execute(f"SELECT {BENUTZER_SPALTEN} FROM benutzer WHERE id = %s", (id,))
row = await cur.fetchone()
return _row_to_user(row) if row else None
async def get_by_email(self, email: str) -> Optional[User]:
async with self.connection.cursor() as cur:
await cur.execute(f"SELECT {BENUTZER_SPALTEN} FROM benutzer WHERE email = %s", (email,))
row = await cur.fetchone()
return _row_to_user(row) if row else None
async def get_by_oauth_account(self, oauth: str, account_id: str) -> Optional[User]:
raise NotImplementedError("OAuth wird nicht unterstützt.")
async def create(self, create_dict: dict) -> User:
user_id = uuid.uuid4()
async with self.connection.cursor() as cur:
await cur.execute(
"""
INSERT INTO benutzer
(id, organization_id, email, hashed_password, is_active, is_superuser,
ist_organisationsadmin, is_verified)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
(
user_id,
create_dict["organization_id"],
create_dict["email"],
create_dict["hashed_password"],
create_dict.get("is_active", True),
create_dict.get("is_superuser", False),
create_dict.get("ist_organisationsadmin", False),
create_dict.get("is_verified", True),
),
)
return await self.get(user_id)
async def update(self, user: User, update_dict: dict) -> User:
felder = ", ".join(f"{spalte} = %s" for spalte in update_dict)
werte = [*update_dict.values(), user.id]
async with self.connection.cursor() as cur:
await cur.execute(f"UPDATE benutzer SET {felder} WHERE id = %s", werte)
return await self.get(user.id)
async def delete(self, user: User) -> None:
async with self.connection.cursor() as cur:
await cur.execute("DELETE FROM benutzer WHERE id = %s", (user.id,))
async def get_user_db():
async with await get_async_database_connection() as connection:
yield UserDatabase(connection)
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret = SESSION_SECRET
verification_token_secret = SESSION_SECRET
async def get_user_manager(user_db: UserDatabase = Depends(get_user_db)):
yield UserManager(user_db)
cookie_transport = CookieTransport(
cookie_name=COOKIE_NAME,
cookie_max_age=COOKIE_MAX_AGE,
cookie_secure=True,
cookie_httponly=True,
cookie_samesite="lax",
)
def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=SESSION_SECRET, lifetime_seconds=COOKIE_MAX_AGE)
auth_backend = AuthenticationBackend(
name="jwt-cookie",
transport=cookie_transport,
get_strategy=get_jwt_strategy,
)
fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend])
_current_user_optional = fastapi_users.current_user(active=True, optional=True)
async def get_current_user(user: Optional[User] = Depends(_current_user_optional)) -> User:
if user is None:
raise HTTPException(status_code=303, headers={"Location": "/login"})
return user
async def require_admin(user: User = Depends(get_current_user)) -> User:
if not user.is_superuser:
raise HTTPException(status_code=403, detail="Kein Admin-Zugriff.")
return user
async def require_org_admin(user: User = Depends(get_current_user)) -> User:
"""
Für Self-Service-Kontenverwaltung (/organisation/konten): erlaubt
entweder is_superuser (internes Tuxflotte-Personal, soll nicht
ausgesperrt sein, falls Support nötig ist) oder ist_organisationsadmin
(die neue, auf die eigene Organisation beschränkte Rolle). Bewusst kein
Ersatz für require_admin - is_superuser bleibt exklusiv für /admin/*.
"""
if not (user.is_superuser or user.ist_organisationsadmin):
raise HTTPException(status_code=403, detail="Kein Admin-Zugriff für diese Organisation.")
return user
def fetch_benutzer_fuer_organisation(organization_id: str) -> list[dict]:
"""
Kontenliste einer Organisation - gemeinsam genutzt vom internen
Admin-Bereich (admin_kunden.py) und der Self-Service-Seite
(routers/organisation.py), um die SQL-Abfrage nicht zu duplizieren
(siehe ADR-0017 fetch_gruppen()-Reuse-Pattern).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, email, is_superuser, ist_organisationsadmin, created_at
FROM benutzer WHERE organization_id = %s ORDER BY email
""",
(organization_id,),
)
ergebnis = []
for benutzer_id, email, is_superuser, ist_organisationsadmin, created_at in cur.fetchall():
# psycopg liefert TEXT-Spalten hier teils als bytes zurück,
# nicht deterministisch (dasselbe bekannte Verhalten wie in
# _row_to_user()) - live gefunden, als ein daraus
# weitergereichter bytes-Wert eine spätere SQL-Abfrage mit
# "operator does not exist: text = bytea" zum Absturz brachte.
if isinstance(email, bytes):
email = email.decode("utf-8")
ergebnis.append({
"id": str(benutzer_id),
"email": email,
"is_superuser": is_superuser,
"ist_organisationsadmin": ist_organisationsadmin,
"created_at": created_at,
})
return ergebnis
router = APIRouter()
@router.get("/login")
async def login_form(request: Request, user: Optional[User] = Depends(_current_user_optional)):
if user is not None:
return RedirectResponse("/geraete", status_code=303)
return templates.TemplateResponse(request, "login.html", {"error": None})
@router.post("/login")
async def login_submit(
request: Request,
email: str = Form(...),
password: str = Form(...),
user_manager: UserManager = Depends(get_user_manager),
):
user = await user_manager.authenticate(SimpleNamespace(username=email, password=password))
if user is None or not user.is_active:
return templates.TemplateResponse(
request, "login.html", {"error": "E-Mail oder Passwort ist falsch."}, status_code=401
)
strategy = get_jwt_strategy()
token = await strategy.write_token(user)
response = RedirectResponse("/geraete", status_code=303)
response.set_cookie(
COOKIE_NAME,
token,
max_age=COOKIE_MAX_AGE,
path="/",
secure=True,
httponly=True,
samesite="lax",
)
return response
@router.get("/logout")
def logout():
response = RedirectResponse("/login", status_code=303)
response.delete_cookie(COOKIE_NAME, path="/")
return response