From 576613ed0fb0cc3a94a4a42d316da1eaea944b1e Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Thu, 6 Aug 2026 08:29:51 +0200 Subject: [PATCH] feat: Auth auf fastapi-users umstellen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ersetzt die handgestrickte bcrypt+Session-Auth durch fastapi-users (CookieTransport+JWTStrategy statt Starlette-Session, custom psycopg3-async-Adapter statt SQLAlchemy). Bestehende bcrypt-Hashes bleiben gültig und werden beim nächsten Login automatisch auf Argon2 angehoben (pwdlib-Default-PasswordHelper). ist_admin wird zu is_superuser, damit fastapi-users' eingebaute Semantik direkt nutzbar ist - require_admin/get_current_user bleiben als Wrapper unter gleichem Namen, alle Router-Dateien bis auf geraete.py (dict- zu attribut-Zugriff auf user.organization_id) unverändert. Lokal end-to-end gegen eine Wegwerf-Postgres-Instanz (podman) verifiziert: Migration, Bestandskonto-Login mit altem Passwort samt Argon2-Upgrade, Admin-Kontoanlage über beide Aufrufstellen (create_user.py und admin_kunden.py), 403 für Nicht-Admins, Logout. Co-Authored-By: Claude Sonnet 5 --- app.py | 6 - auth.py | 213 +++++++++++++++++++++---- db.py | 7 + migrations/0003_fastapi_users_auth.sql | 8 + requirements.txt | 12 +- routers/admin_kunden.py | 35 ++-- routers/geraete.py | 8 +- scripts/create_user.py | 37 +++-- templates/admin/kunden_detail.html | 4 +- templates/base.html | 2 +- 10 files changed, 246 insertions(+), 86 deletions(-) create mode 100644 migrations/0003_fastapi_users_auth.sql diff --git a/app.py b/app.py index 7d77684..7a9b2fe 100644 --- a/app.py +++ b/app.py @@ -1,17 +1,11 @@ -import os - from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from starlette.middleware.sessions import SessionMiddleware import auth from routers import admin_flows, admin_geraete, admin_kunden, admin_technik, geraete -SESSION_SECRET = os.environ.get("KUNDENPLATTFORM_SESSION_SECRET", "") - app = FastAPI(title="Tuxflotte Kundenplattform") -app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET, session_cookie="kundenplattform_session") app.mount("/static", StaticFiles(directory="static"), name="static") app.include_router(auth.router) diff --git a/auth.py b/auth.py index dedfaac..f7be8cf 100644 --- a/auth.py +++ b/auth.py @@ -1,73 +1,216 @@ -import bcrypt +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_database_connection +from db import get_async_database_connection from templating import templates -router = APIRouter() +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, is_verified" -def get_current_user(request: Request) -> dict: - user = request.session.get("user") +@dataclass +class User: + id: uuid.UUID + email: str + hashed_password: str + organization_id: uuid.UUID + is_active: bool = True + is_superuser: bool = False + is_verified: bool = True + +class UserCreate(schemas.BaseUserCreate): + organization_id: uuid.UUID + is_verified: Optional[bool] = True + + +def _row_to_user(row) -> User: + user_id, organization_id, email, hashed_password, is_active, is_superuser, 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") + + return User( + id=user_id, + organization_id=organization_id, + email=email, + hashed_password=hashed_password, + is_active=is_active, + is_superuser=is_superuser, + 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, is_verified) + VALUES (%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("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 -def require_admin(user: dict = Depends(get_current_user)) -> dict: - if not user.get("ist_admin"): +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 +router = APIRouter() + + @router.get("/login") -def login_form(request: Request): - if request.session.get("user") is not None: +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") -def login_submit(request: Request, email: str = Form(...), password: str = Form(...)): - with get_database_connection() as conn: - with conn.cursor() as cur: - cur.execute( - "SELECT id, organization_id, password_hash, ist_admin FROM benutzer WHERE email = %s", - (email,), - ) - row = cur.fetchone() +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 row is None: + 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 ) - # psycopg liefert TEXT-Spalten hier als bytes zurück, nicht als str. - stored_hash = row[2] if isinstance(row[2], bytes) else row[2].encode("utf-8") + strategy = get_jwt_strategy() + token = await strategy.write_token(user) - if not bcrypt.checkpw(password.encode("utf-8"), stored_hash): - return templates.TemplateResponse( - request, "login.html", {"error": "E-Mail oder Passwort ist falsch."}, status_code=401 - ) + response = RedirectResponse("/geraete", status_code=303) + response.set_cookie( + COOKIE_NAME, + token, + max_age=COOKIE_MAX_AGE, + path="/", + secure=True, + httponly=True, + samesite="lax", + ) - request.session["user"] = { - "id": str(row[0]), - "organization_id": str(row[1]), - "email": email, - "ist_admin": row[3], - } - - return RedirectResponse("/geraete", status_code=303) + return response @router.get("/logout") -def logout(request: Request): - request.session.clear() +def logout(): + response = RedirectResponse("/login", status_code=303) + response.delete_cookie(COOKIE_NAME, path="/") - return RedirectResponse("/login", status_code=303) + return response diff --git a/db.py b/db.py index 324ad9f..549a619 100644 --- a/db.py +++ b/db.py @@ -11,3 +11,10 @@ def get_database_connection(): raise RuntimeError("KUNDENPLATTFORM_DATABASE_URL ist nicht gesetzt.") return psycopg.connect(DATABASE_URL) + + +async def get_async_database_connection(): + if not DATABASE_URL: + raise RuntimeError("KUNDENPLATTFORM_DATABASE_URL ist nicht gesetzt.") + + return await psycopg.AsyncConnection.connect(DATABASE_URL) diff --git a/migrations/0003_fastapi_users_auth.sql b/migrations/0003_fastapi_users_auth.sql new file mode 100644 index 0000000..9ca3159 --- /dev/null +++ b/migrations/0003_fastapi_users_auth.sql @@ -0,0 +1,8 @@ +BEGIN; + +ALTER TABLE benutzer RENAME COLUMN password_hash TO hashed_password; +ALTER TABLE benutzer RENAME COLUMN ist_admin TO is_superuser; +ALTER TABLE benutzer ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE benutzer ADD COLUMN is_verified BOOLEAN NOT NULL DEFAULT TRUE; + +COMMIT; diff --git a/requirements.txt b/requirements.txt index cb45fbe..1b681c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,31 @@ annotated-doc==0.0.5 annotated-types==0.8.0 anyio==4.14.2 +argon2-cffi==25.1.0 +argon2-cffi-bindings==25.1.0 bcrypt==5.0.0 certifi==2026.7.22 +cffi==2.1.1 click==8.4.2 +cryptography==50.0.0 +dnspython==2.8.0 +email-validator==2.3.0 fastapi==0.141.1 +fastapi-users==15.0.5 h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 idna==3.18 -itsdangerous==2.2.0 Jinja2==3.1.6 +makefun==1.16.0 MarkupSafe==3.0.3 psycopg==3.3.4 psycopg-binary==3.3.4 +pwdlib==0.3.0 +pycparser==3.0 pydantic==2.13.4 pydantic_core==2.46.4 +PyJWT==2.13.0 python-multipart==0.0.32 starlette==1.3.1 typing-inspection==0.4.2 diff --git a/routers/admin_kunden.py b/routers/admin_kunden.py index 54240de..ab9dcad 100644 --- a/routers/admin_kunden.py +++ b/routers/admin_kunden.py @@ -1,11 +1,8 @@ -from uuid import uuid4 - -import bcrypt from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import RedirectResponse from anode_client import anode_request -from auth import require_admin +from auth import UserCreate, UserManager, get_user_manager, require_admin from db import get_database_connection from templating import templates @@ -17,12 +14,12 @@ def fetch_benutzer_fuer_organisation(organization_id: str) -> list[dict]: with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( - "SELECT email, ist_admin, created_at FROM benutzer WHERE organization_id = %s ORDER BY email", + "SELECT email, is_superuser, created_at FROM benutzer WHERE organization_id = %s ORDER BY email", (organization_id,), ) return [ - {"email": email, "ist_admin": ist_admin, "created_at": created_at} - for email, ist_admin, created_at in cur.fetchall() + {"email": email, "is_superuser": is_superuser, "created_at": created_at} + for email, is_superuser, created_at in cur.fetchall() ] @@ -75,23 +72,21 @@ def aktivierungscode_anlegen(organization_id: str, user: dict = Depends(require_ @router.post("/{organization_id}/konten") -def konto_anlegen( +async def konto_anlegen( organization_id: str, email: str = Form(...), password: str = Form(...), - ist_admin: str = Form(default=""), + is_superuser: str = Form(default=""), user: dict = Depends(require_admin), + user_manager: UserManager = Depends(get_user_manager), ): - password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") - - with get_database_connection() as conn: - with conn.cursor() as cur: - cur.execute( - """ - INSERT INTO benutzer (id, organization_id, email, password_hash, ist_admin) - VALUES (%s, %s, %s, %s, %s) - """, - (uuid4(), organization_id, email, password_hash, ist_admin == "on"), - ) + await user_manager.create( + UserCreate( + email=email, + password=password, + organization_id=organization_id, + is_superuser=is_superuser == "on", + ) + ) return RedirectResponse(f"/admin/kunden/{organization_id}", status_code=303) diff --git a/routers/geraete.py b/routers/geraete.py index ac94312..fe7cbcf 100644 --- a/routers/geraete.py +++ b/routers/geraete.py @@ -55,7 +55,7 @@ def index(user: dict = Depends(get_current_user)): @router.get("/geraete") def geraete_liste(request: Request, user: dict = Depends(get_current_user)): - result = anode_request("GET", f"/api/v1/organizations/{user['organization_id']}/devices") + result = anode_request("GET", f"/api/v1/organizations/{user.organization_id}/devices") devices = result.get("devices", []) if result.get("success") else [] return templates.TemplateResponse( @@ -65,7 +65,7 @@ def geraete_liste(request: Request, user: dict = Depends(get_current_user)): @router.get("/geraete/{device_id}") def auftragskatalog_ansicht(request: Request, device_id: str, user: dict = Depends(get_current_user)): - device = find_device_in_organization(device_id, user["organization_id"]) + device = find_device_in_organization(device_id, user.organization_id) if device is None: raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.") @@ -92,7 +92,7 @@ def auftragskatalog_ansicht(request: Request, device_id: str, user: dict = Depen async def auftrag_select( request: Request, device_id: str, merkmal_key: str, user: dict = Depends(get_current_user) ): - if find_device_in_organization(device_id, user["organization_id"]) is None: + if find_device_in_organization(device_id, user.organization_id) is None: raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.") form = await request.form() @@ -112,7 +112,7 @@ async def auftrag_select( @router.post("/geraete/{device_id}/auftragskatalog/{merkmal_key}/deselect") def auftrag_deselect(device_id: str, merkmal_key: str, user: dict = Depends(get_current_user)): - if find_device_in_organization(device_id, user["organization_id"]) is None: + if find_device_in_organization(device_id, user.organization_id) is None: raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.") anode_request( diff --git a/scripts/create_user.py b/scripts/create_user.py index 26d2004..222388f 100644 --- a/scripts/create_user.py +++ b/scripts/create_user.py @@ -8,13 +8,28 @@ ADR-0011). Beispiel: """ import argparse +import asyncio import getpass import os import sys -from uuid import uuid4 -import bcrypt -import psycopg +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from auth import UserCreate, UserDatabase, UserManager # noqa: E402 +from db import get_async_database_connection # noqa: E402 + + +async def konto_anlegen(organization_id: str, email: str, password: str, admin: bool): + async with await get_async_database_connection() as connection: + user_manager = UserManager(UserDatabase(connection)) + await user_manager.create( + UserCreate( + email=email, + password=password, + organization_id=organization_id, + is_superuser=admin, + ) + ) def main(): @@ -27,9 +42,7 @@ def main(): ) args = parser.parse_args() - database_url = os.environ.get("KUNDENPLATTFORM_DATABASE_URL") - - if not database_url: + if not os.environ.get("KUNDENPLATTFORM_DATABASE_URL"): sys.exit("KUNDENPLATTFORM_DATABASE_URL ist nicht gesetzt.") password = getpass.getpass("Passwort: ") @@ -38,17 +51,7 @@ def main(): if password != password_confirm: sys.exit("Passwörter stimmen nicht überein.") - password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") - - with psycopg.connect(database_url) as conn: - with conn.cursor() as cur: - cur.execute( - """ - INSERT INTO benutzer (id, organization_id, email, password_hash, ist_admin) - VALUES (%s, %s, %s, %s, %s) - """, - (uuid4(), args.organization_id, args.email, password_hash, args.admin), - ) + asyncio.run(konto_anlegen(args.organization_id, args.email, password, args.admin)) print(f"Konto für {args.email} angelegt{' (Admin)' if args.admin else ''}.") diff --git a/templates/admin/kunden_detail.html b/templates/admin/kunden_detail.html index cee6d7e..14ecd72 100644 --- a/templates/admin/kunden_detail.html +++ b/templates/admin/kunden_detail.html @@ -34,7 +34,7 @@ {% for b in benutzer %} {{ b.email }} - {{ "ja" if b.ist_admin else "nein" }} + {{ "ja" if b.is_superuser else "nein" }} {{ b.created_at }} {% endfor %} @@ -49,7 +49,7 @@ diff --git a/templates/base.html b/templates/base.html index c561d92..84b05a4 100644 --- a/templates/base.html +++ b/templates/base.html @@ -18,7 +18,7 @@ {% if user %}