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 import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
import auth import auth
from routers import admin_flows, admin_geraete, admin_kunden, admin_technik, geraete 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 = 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.mount("/static", StaticFiles(directory="static"), name="static")
app.include_router(auth.router) app.include_router(auth.router)

213
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 import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse 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 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: @dataclass
user = request.session.get("user") 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: if user is None:
raise HTTPException(status_code=303, headers={"Location": "/login"}) raise HTTPException(status_code=303, headers={"Location": "/login"})
return user return user
def require_admin(user: dict = Depends(get_current_user)) -> dict: async def require_admin(user: User = Depends(get_current_user)) -> User:
if not user.get("ist_admin"): if not user.is_superuser:
raise HTTPException(status_code=403, detail="Kein Admin-Zugriff.") raise HTTPException(status_code=403, detail="Kein Admin-Zugriff.")
return user return user
router = APIRouter()
@router.get("/login") @router.get("/login")
def login_form(request: Request): async def login_form(request: Request, user: Optional[User] = Depends(_current_user_optional)):
if request.session.get("user") is not None: if user is not None:
return RedirectResponse("/geraete", status_code=303) return RedirectResponse("/geraete", status_code=303)
return templates.TemplateResponse(request, "login.html", {"error": None}) return templates.TemplateResponse(request, "login.html", {"error": None})
@router.post("/login") @router.post("/login")
def login_submit(request: Request, email: str = Form(...), password: str = Form(...)): async def login_submit(
with get_database_connection() as conn: request: Request,
with conn.cursor() as cur: email: str = Form(...),
cur.execute( password: str = Form(...),
"SELECT id, organization_id, password_hash, ist_admin FROM benutzer WHERE email = %s", user_manager: UserManager = Depends(get_user_manager),
(email,), ):
) user = await user_manager.authenticate(SimpleNamespace(username=email, password=password))
row = cur.fetchone()
if row is None: if user is None or not user.is_active:
return templates.TemplateResponse( return templates.TemplateResponse(
request, "login.html", {"error": "E-Mail oder Passwort ist falsch."}, status_code=401 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. strategy = get_jwt_strategy()
stored_hash = row[2] if isinstance(row[2], bytes) else row[2].encode("utf-8") token = await strategy.write_token(user)
if not bcrypt.checkpw(password.encode("utf-8"), stored_hash): response = RedirectResponse("/geraete", status_code=303)
return templates.TemplateResponse( response.set_cookie(
request, "login.html", {"error": "E-Mail oder Passwort ist falsch."}, status_code=401 COOKIE_NAME,
) token,
max_age=COOKIE_MAX_AGE,
path="/",
secure=True,
httponly=True,
samesite="lax",
)
request.session["user"] = { return response
"id": str(row[0]),
"organization_id": str(row[1]),
"email": email,
"ist_admin": row[3],
}
return RedirectResponse("/geraete", status_code=303)
@router.get("/logout") @router.get("/logout")
def logout(request: Request): def logout():
request.session.clear() 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.") raise RuntimeError("KUNDENPLATTFORM_DATABASE_URL ist nicht gesetzt.")
return psycopg.connect(DATABASE_URL) 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-doc==0.0.5
annotated-types==0.8.0 annotated-types==0.8.0
anyio==4.14.2 anyio==4.14.2
argon2-cffi==25.1.0
argon2-cffi-bindings==25.1.0
bcrypt==5.0.0 bcrypt==5.0.0
certifi==2026.7.22 certifi==2026.7.22
cffi==2.1.1
click==8.4.2 click==8.4.2
cryptography==50.0.0
dnspython==2.8.0
email-validator==2.3.0
fastapi==0.141.1 fastapi==0.141.1
fastapi-users==15.0.5
h11==0.16.0 h11==0.16.0
httpcore==1.0.9 httpcore==1.0.9
httpx==0.28.1 httpx==0.28.1
idna==3.18 idna==3.18
itsdangerous==2.2.0
Jinja2==3.1.6 Jinja2==3.1.6
makefun==1.16.0
MarkupSafe==3.0.3 MarkupSafe==3.0.3
psycopg==3.3.4 psycopg==3.3.4
psycopg-binary==3.3.4 psycopg-binary==3.3.4
pwdlib==0.3.0
pycparser==3.0
pydantic==2.13.4 pydantic==2.13.4
pydantic_core==2.46.4 pydantic_core==2.46.4
PyJWT==2.13.0
python-multipart==0.0.32 python-multipart==0.0.32
starlette==1.3.1 starlette==1.3.1
typing-inspection==0.4.2 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 import APIRouter, Depends, Form, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from anode_client import anode_request 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 db import get_database_connection
from templating import templates 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 get_database_connection() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( 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,), (organization_id,),
) )
return [ return [
{"email": email, "ist_admin": ist_admin, "created_at": created_at} {"email": email, "is_superuser": is_superuser, "created_at": created_at}
for email, ist_admin, created_at in cur.fetchall() 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") @router.post("/{organization_id}/konten")
def konto_anlegen( async def konto_anlegen(
organization_id: str, organization_id: str,
email: str = Form(...), email: str = Form(...),
password: str = Form(...), password: str = Form(...),
ist_admin: str = Form(default=""), is_superuser: str = Form(default=""),
user: dict = Depends(require_admin), 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") await user_manager.create(
UserCreate(
with get_database_connection() as conn: email=email,
with conn.cursor() as cur: password=password,
cur.execute( organization_id=organization_id,
""" is_superuser=is_superuser == "on",
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"),
)
return RedirectResponse(f"/admin/kunden/{organization_id}", status_code=303) 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") @router.get("/geraete")
def geraete_liste(request: Request, user: dict = Depends(get_current_user)): 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 [] devices = result.get("devices", []) if result.get("success") else []
return templates.TemplateResponse( return templates.TemplateResponse(
@ -65,7 +65,7 @@ def geraete_liste(request: Request, user: dict = Depends(get_current_user)):
@router.get("/geraete/{device_id}") @router.get("/geraete/{device_id}")
def auftragskatalog_ansicht(request: Request, device_id: str, user: dict = Depends(get_current_user)): 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: if device is None:
raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.") 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( async def auftrag_select(
request: Request, device_id: str, merkmal_key: str, user: dict = Depends(get_current_user) 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.") raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.")
form = await request.form() form = await request.form()
@ -112,7 +112,7 @@ async def auftrag_select(
@router.post("/geraete/{device_id}/auftragskatalog/{merkmal_key}/deselect") @router.post("/geraete/{device_id}/auftragskatalog/{merkmal_key}/deselect")
def auftrag_deselect(device_id: str, merkmal_key: str, user: dict = Depends(get_current_user)): 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.") raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.")
anode_request( anode_request(

View File

@ -8,13 +8,28 @@ ADR-0011). Beispiel:
""" """
import argparse import argparse
import asyncio
import getpass import getpass
import os import os
import sys import sys
from uuid import uuid4
import bcrypt sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import psycopg
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(): def main():
@ -27,9 +42,7 @@ def main():
) )
args = parser.parse_args() args = parser.parse_args()
database_url = os.environ.get("KUNDENPLATTFORM_DATABASE_URL") if not os.environ.get("KUNDENPLATTFORM_DATABASE_URL"):
if not database_url:
sys.exit("KUNDENPLATTFORM_DATABASE_URL ist nicht gesetzt.") sys.exit("KUNDENPLATTFORM_DATABASE_URL ist nicht gesetzt.")
password = getpass.getpass("Passwort: ") password = getpass.getpass("Passwort: ")
@ -38,17 +51,7 @@ def main():
if password != password_confirm: if password != password_confirm:
sys.exit("Passwörter stimmen nicht überein.") sys.exit("Passwörter stimmen nicht überein.")
password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") asyncio.run(konto_anlegen(args.organization_id, args.email, password, args.admin))
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),
)
print(f"Konto für {args.email} angelegt{' (Admin)' if args.admin else ''}.") print(f"Konto für {args.email} angelegt{' (Admin)' if args.admin else ''}.")

View File

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

View File

@ -18,7 +18,7 @@
<ul><li><strong><a href="/geraete">Tuxflotte Kundenplattform</a></strong></li></ul> <ul><li><strong><a href="/geraete">Tuxflotte Kundenplattform</a></strong></li></ul>
{% if user %} {% if user %}
<ul> <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>{{ user.email }}</li>
<li><a href="/logout" role="button" class="secondary outline">Abmelden</a></li> <li><a href="/logout" role="button" class="secondary outline">Abmelden</a></li>
</ul> </ul>