Phase 1: Kern mit Plugin-System, Auth/Rollen, Migrations, Plugin-Stubs

- redaktionskern (src/): schlanker Kern — App-Fabrik, Plugin-Loader
  (Entry-Points + plugins/-Verzeichnis), Migrations-Laufzeit
  (schema_migrations pro Plugin, SQLite-/Postgres-portabel),
  Auth mit Argon2id + Session-Cookies, Rollen admin/redakteur/rezensent,
  Benutzerverwaltung für Admins
- plugins/: 8 ladbare Stubs (neuheiten, dedup, planung, archiv,
  erinnerung, benachrichtigung, audit-log, export) nach Plugin-Vertrag
- Frontend: Jinja2 + Tailwind (CDN) + HTMX + Alpine.js, UI deutsch
- Tests: 28 pytest-Fälle (Loader, Lifecycle, Entry-Points, Migrations-
  Idempotenz, Login/Logout, Rollen-Zugriff, Benutzerverwaltung)
- Docker/Podman: Compose (Traefik-Labels) + Dockerfile (uv)
- README.md mit Setup-Anleitung
This commit is contained in:
Flo Hartmann
2026-08-21 18:35:09 +00:00
parent 72f016a263
commit 96a37e878b
50 changed files with 3343 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Authentifizierung und Autorisierung (Kern-Infrastruktur)."""

View File

@@ -0,0 +1,48 @@
"""FastAPI-Abhängigkeiten für Authentifizierung und Rollen."""
from __future__ import annotations
from fastapi import Depends, Request
from sqlalchemy.orm import Session
from redaktionskern.auth.models import User
class NotAuthenticated(Exception):
"""Benutzer ist nicht (mehr) angemeldet → Weiterleitung zum Login."""
class AccessDenied(Exception):
"""Benutzer hat die nötige Rolle nicht → 403-Seite."""
def get_db(request: Request):
"""Stellt eine Datenbank-Session pro Anfrage bereit."""
db: Session = request.app.state.session_factory()
try:
yield db
finally:
db.close()
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
user_id = request.session.get("user_id")
if user_id is None:
return None
return db.get(User, user_id)
def require_user(user: User | None = Depends(get_current_user)) -> User:
if user is None or not user.active:
raise NotAuthenticated
return user
def require_roles(*roles: str):
"""Abhängigkeits-Fabrik: verlangt eine der angegebenen Rollen."""
def abhaengigkeit(user: User = Depends(require_user)) -> User:
if user.role not in roles:
raise AccessDenied(f"Rolle '{user.role}' hat keinen Zugriff.")
return user
return abhaengigkeit

View File

@@ -0,0 +1,39 @@
"""Benutzer-Model des Kerns."""
from __future__ import annotations
import enum
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from redaktionskern.db import Base
class Role(str, enum.Enum):
ADMIN = "admin"
REDAKTEUR = "redakteur"
REZENSENT = "rezensent"
ROLEN: list[str] = [rolle.value for rolle in Role]
ROLLEN_ANZEIGE: dict[str, str] = {
Role.ADMIN.value: "Administrator",
Role.REDAKTEUR.value: "Redakteur",
Role.REZENSENT.value: "Rezensent",
}
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
display_name: Mapped[str] = mapped_column(String(200))
password_hash: Mapped[str] = mapped_column(String(300))
role: Mapped[str] = mapped_column(String(20), default=Role.REDAKTEUR.value)
active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)

View File

@@ -0,0 +1,232 @@
"""Routen: Login/Logout, Dashboard und Benutzerverwaltung (nur Admin)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_current_user, get_db, require_roles, require_user
from redaktionskern.auth.models import ROLEN, Role, User
from redaktionskern.auth.security import hash_password, verify_password
router = APIRouter()
admin_router = APIRouter(
prefix="/admin", dependencies=[Depends(require_roles(Role.ADMIN.value))]
)
def _render(request: Request, name: str, status_code: int = 200, **kontext):
return request.app.state.templates.TemplateResponse(
request=request, name=name, context=kontext, status_code=status_code
)
# ---------------- Dashboard ----------------
@router.get("/")
def dashboard(request: Request, user: User = Depends(require_user)):
return _render(request, "dashboard.html", user=user)
# ---------------- Login / Logout ----------------
@router.get("/login")
def login_form(
request: Request, user: User | None = Depends(get_current_user)
):
if user is not None:
return RedirectResponse("/", status_code=303)
return _render(request, "login.html", user=None, fehler=None, username="")
@router.post("/login")
def login(
request: Request,
db: Session = Depends(get_db),
username: str = Form(...),
password: str = Form(...),
):
username = username.strip()
user = db.scalar(select(User).where(User.username == username))
fehler: str | None
if user is None or not verify_password(user.password_hash, password):
fehler = "Benutzername oder Passwort ist falsch."
elif not user.active:
fehler = "Dieses Konto ist deaktiviert."
else:
fehler = None
if fehler is not None:
return _render(
request, "login.html", user=None, fehler=fehler, username=username
)
request.session["user_id"] = user.id
return RedirectResponse("/", status_code=303)
@router.post("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
# ---------------- Benutzerverwaltung (Admin) ----------------
def _formular_werte(benutzer: User | None) -> dict:
if benutzer is None:
return {"username": "", "display_name": "", "role": Role.REDAKTEUR.value}
return {
"username": benutzer.username,
"display_name": benutzer.display_name,
"role": benutzer.role,
}
@admin_router.get("/benutzer")
def benutzer_liste(
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
benutzer = db.scalars(select(User).order_by(User.username)).all()
return _render(
request, "benutzer/liste.html", user=user, benutzer=benutzer, fehler=None
)
@admin_router.get("/benutzer/neu")
def benutzer_neu_form(request: Request, user: User = Depends(require_user)):
return _render(
request,
"benutzer/formular.html",
user=user,
aktion="neu",
ziel=None,
form=_formular_werte(None),
fehler=None,
)
def _validiere_neuen_benutzer(
db: Session, username: str, role: str, password: str
) -> str | None:
if len(username) < 3:
return "Der Benutzername muss mindestens 3 Zeichen lang sein."
if db.scalar(select(User).where(User.username == username)) is not None:
return "Dieser Benutzername ist bereits vergeben."
if role not in ROLEN:
return "Unbekannte Rolle."
if len(password) < 8:
return "Das Passwort muss mindestens 8 Zeichen lang sein."
return None
@admin_router.post("/benutzer/neu")
def benutzer_neu(
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
username: str = Form(...),
display_name: str = Form(""),
role: str = Form(...),
password: str = Form(...),
):
username = username.strip()
fehler = _validiere_neuen_benutzer(db, username, role, password)
if fehler is not None:
return _render(
request,
"benutzer/formular.html",
status_code=400,
user=user,
aktion="neu",
ziel=None,
form={"username": username, "display_name": display_name, "role": role},
fehler=fehler,
)
db.add(
User(
username=username,
display_name=display_name.strip() or username,
password_hash=hash_password(password),
role=role,
active=True,
)
)
db.commit()
return RedirectResponse("/admin/benutzer", status_code=303)
def _lade_oder_404(db: Session, benutzer_id: int) -> User:
ziel = db.get(User, benutzer_id)
if ziel is None:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden.")
return ziel
@admin_router.get("/benutzer/{benutzer_id}")
def benutzer_bearbeiten_form(
benutzer_id: int,
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
ziel = _lade_oder_404(db, benutzer_id)
return _render(
request,
"benutzer/formular.html",
user=user,
aktion="bearbeiten",
ziel=ziel,
form=_formular_werte(ziel),
fehler=None,
)
@admin_router.post("/benutzer/{benutzer_id}")
def benutzer_bearbeiten(
benutzer_id: int,
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
display_name: str = Form(""),
role: str = Form(...),
active: str = Form(""),
password: str = Form(""),
):
ziel = _lade_oder_404(db, benutzer_id)
aktiv = active == "on"
fehler: str | None
if role not in ROLEN:
fehler = "Unbekannte Rolle."
elif password and len(password) < 8:
fehler = "Das Passwort muss mindestens 8 Zeichen lang sein."
elif ziel.id == user.id and (role != Role.ADMIN.value or not aktiv):
fehler = "Sie können Ihre eigene Rolle oder Ihren eigenen Status nicht ändern."
else:
fehler = None
if fehler is not None:
return _render(
request,
"benutzer/formular.html",
status_code=400,
user=user,
aktion="bearbeiten",
ziel=ziel,
form={"username": ziel.username, "display_name": display_name, "role": role},
fehler=fehler,
)
ziel.display_name = display_name.strip() or ziel.username
ziel.role = role
ziel.active = aktiv
if password:
ziel.password_hash = hash_password(password)
db.commit()
return RedirectResponse("/admin/benutzer", status_code=303)
@admin_router.get("/benutzern-anzahl")
def benutzer_anzahl(db: Session = Depends(get_db)):
"""Kleine Diagnose-Route für den Admin (zeigt, dass die DB lebt)."""
anzahl = db.scalar(select(func.count()).select_from(User))
return {"anzahl": anzahl}

View File

@@ -0,0 +1,19 @@
"""Passwort-Hashing mit Argon2id."""
from __future__ import annotations
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
_hasher = PasswordHasher()
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
try:
_hasher.verify(password_hash, password)
except (VerifyMismatchError, InvalidHashError):
return False
return True