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:
1
src/redaktionskern/auth/__init__.py
Normal file
1
src/redaktionskern/auth/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Authentifizierung und Autorisierung (Kern-Infrastruktur)."""
|
||||
48
src/redaktionskern/auth/deps.py
Normal file
48
src/redaktionskern/auth/deps.py
Normal 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
|
||||
39
src/redaktionskern/auth/models.py
Normal file
39
src/redaktionskern/auth/models.py
Normal 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()
|
||||
)
|
||||
232
src/redaktionskern/auth/routen.py
Normal file
232
src/redaktionskern/auth/routen.py
Normal 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}
|
||||
19
src/redaktionskern/auth/security.py
Normal file
19
src/redaktionskern/auth/security.py
Normal 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
|
||||
Reference in New Issue
Block a user