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:
parent
b4bd160536
commit
576613ed0f
6
app.py
6
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)
|
||||
|
||||
211
auth.py
211
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
|
||||
|
||||
7
db.py
7
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)
|
||||
|
||||
8
migrations/0003_fastapi_users_auth.sql
Normal file
8
migrations/0003_fastapi_users_auth.sql
Normal 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;
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 ''}.")
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user