feat: Auth auf fastapi-users umstellen

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-06 08:29:51 +02:00
parent b4bd160536
commit 576613ed0f
10 changed files with 246 additions and 86 deletions

6
app.py
View File

@ -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)

211
auth.py
View File

@ -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

7
db.py
View File

@ -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)

View File

@ -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;

View File

@ -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

View File

@ -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)

View File

@ -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(

View File

@ -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 ''}.")

View File

@ -34,7 +34,7 @@
{% for b in benutzer %}
<tr>
<td>{{ b.email }}</td>
<td>{{ "ja" if b.ist_admin else "nein" }}</td>
<td>{{ "ja" if b.is_superuser else "nein" }}</td>
<td>{{ b.created_at }}</td>
</tr>
{% endfor %}
@ -49,7 +49,7 @@
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required>
<label>
<input type="checkbox" name="ist_admin">
<input type="checkbox" name="is_superuser">
Admin-Zugriff (organisationsübergreifend)
</label>
<button type="submit">Anlegen</button>

View File

@ -18,7 +18,7 @@
<ul><li><strong><a href="/geraete">Tuxflotte Kundenplattform</a></strong></li></ul>
{% if user %}
<ul>
{% if user.ist_admin %}<li><a href="/admin/technik">Admin</a></li>{% endif %}
{% if user.is_superuser %}<li><a href="/admin/technik">Admin</a></li>{% endif %}
<li>{{ user.email }}</li>
<li><a href="/logout" role="button" class="secondary outline">Abmelden</a></li>
</ul>