feat: Admin-Seite mit Passwortaenderung und Regel-Neuanwendung

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 08:24:33 +02:00
parent 74684c7e6c
commit f5fa3e45e8
13 changed files with 690 additions and 2 deletions

View File

@@ -1,6 +1,8 @@
import hashlib
import hmac
import re
import secrets
from pathlib import Path
from fastapi import HTTPException, Request
from itsdangerous import BadSignature, TimestampSigner
@@ -10,6 +12,48 @@ from app.config import get_settings
COOKIE = "fb_session"
MAX_AGE = 60 * 60 * 12 # 12 h
# mtime-Cache fuer den aus der (ggf. bind-gemounteten) .env gelesenen
# GUI-Passwort-Hash: {Pfad: (mtime, hash)}. Vermeidet, die Datei bei jedem
# Login/Request neu zu parsen, invalidiert sich aber automatisch, sobald
# services.admin.change_password() die Datei in-place neu schreibt (neue
# mtime).
_hash_cache: dict[str, tuple[float, str]] = {}
_ENV_LINE_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)='([^']*)'\s*$")
def _parse_env_file(env_file: Path) -> dict[str, str]:
"""Parst single-quoted KEY='value'-Zeilen wie sie create_pod_finance.sh
schreibt. Zeilen in anderer Form (Kommentare, unquoted, leer) werden
ignoriert statt einen Fehler zu werfen."""
values: dict[str, str] = {}
for line in env_file.read_text().splitlines():
m = _ENV_LINE_RE.match(line)
if m:
values[m.group(1)] = m.group(2)
return values
def current_password_hash() -> str:
"""Liest FB_GUI_PASSWORD_HASH zur Laufzeit aus der .env (Bind-Mount,
siehe KRITISCH-Hinweis Global Constraints Ausbaustufe 4: Einzeldatei-Mount
folgt dem Inode, die Datei wird von services.admin.change_password()
in-place ueberschrieben). Existiert die Datei nicht (z.B. lokale
Entwicklung ohne Pod), wird auf die Umgebungsvariable zurueckgefallen."""
settings = get_settings()
env_file = settings.env_file
if env_file.exists():
mtime = env_file.stat().st_mtime
key = str(env_file)
cached = _hash_cache.get(key)
if cached is not None and cached[0] == mtime:
return cached[1]
value = _parse_env_file(env_file).get("FB_GUI_PASSWORD_HASH")
if value is not None:
_hash_cache[key] = (mtime, value)
return value
return settings.gui_password_hash
def hash_password(pw: str, salt: str | None = None) -> str:
salt = salt or secrets.token_hex(16)

View File

@@ -16,6 +16,8 @@ class Settings:
uploads_dir: Path
warn_threshold: Decimal
horizon_days: int
env_file: Path
grafana_url: str
@lru_cache
@@ -31,4 +33,9 @@ def get_settings() -> Settings:
uploads_dir=Path(e("FB_UPLOADS_DIR", "./uploads")),
warn_threshold=Decimal(e("FB_WARN_THRESHOLD", "0")),
horizon_days=int(e("FB_HORIZON_DAYS", "548")),
# Bind-gemountete .env (siehe create_pod_finance.sh): Laufzeit-Lesen
# der GUI-Passwort-Hashes fuer die Admin-Passwortaenderung (Ausbaustufe
# 4 Task 2). Default passt zum Container-Mountpunkt "/data/.env".
env_file=Path(e("FB_ENV_FILE", "/data/.env")),
grafana_url=e("FB_GRAFANA_URL", "http://localhost:3000"),
)

View File

@@ -10,7 +10,7 @@ from app.auth import require_auth
from app.config import get_settings
from app.db import get_engine
from app.models.views import create_views
from app.routers import (accounts, categories, gui, imports, planning,
from app.routers import (accounts, admin, categories, gui, imports, planning,
scenarios, transactions)
from app.routers.gui import templates
from app.version import get_version
@@ -33,6 +33,7 @@ app.include_router(categories.router)
app.include_router(imports.router)
app.include_router(planning.router)
app.include_router(scenarios.router)
app.include_router(admin.router)
@app.get("/login")
@@ -44,7 +45,7 @@ def login_form(request: Request):
def login(username: str = Form(...), password: str = Form(...)):
s = get_settings()
if not (hmac.compare_digest(username, s.gui_user)
and auth.verify_password(password, s.gui_password_hash)):
and auth.verify_password(password, auth.current_password_hash())):
return HTMLResponse("Login fehlgeschlagen", status_code=401)
resp = RedirectResponse("/", status_code=303)
resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True,

View File

@@ -0,0 +1,52 @@
from fastapi import APIRouter, Depends, Form, Request
from sqlalchemy.orm import Session
from app.auth import require_auth
from app.db import get_session
from app.routers.gui import gui_session, templates
from app.services.admin import apply_rules_retroactively, change_password
router = APIRouter()
@router.get("/admin", dependencies=[Depends(gui_session)])
def admin_page(request: Request):
return templates.TemplateResponse(request, "admin.html", {"error": None, "success": None})
@router.post("/admin/passwort", dependencies=[Depends(gui_session)])
def admin_change_password(
request: Request,
alt: str = Form(...),
neu: str = Form(...),
neu2: str = Form(...),
):
# UX-Regel: das Formular selbst bleibt immer sichtbar/bedienbar; Fehler
# werden inline auf derselben Seite gemeldet statt still zu verwerfen.
if len(neu) < 8:
return templates.TemplateResponse(request, "admin.html", {
"error": "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
"success": None,
}, status_code=400)
if neu != neu2:
return templates.TemplateResponse(request, "admin.html", {
"error": "Die Wiederholung stimmt nicht mit dem neuen Passwort überein.",
"success": None,
}, status_code=400)
try:
change_password(alt, neu)
except (ValueError, RuntimeError) as exc:
return templates.TemplateResponse(request, "admin.html", {
"error": str(exc),
"success": None,
}, status_code=400)
return templates.TemplateResponse(request, "admin.html", {
"error": None,
"success": "Passwort erfolgreich geändert (gilt für GUI und Grafana). "
"Bestehende Sitzungen bleiben angemeldet.",
})
@router.post("/api/category-rules/apply", dependencies=[Depends(require_auth)])
def apply_category_rules(session: Session = Depends(get_session)):
return {"categorized": apply_rules_retroactively(session)}

View File

@@ -0,0 +1,116 @@
"""Admin-Funktionen: Passwortaenderung (GUI+Grafana gemeinsam) und
Regel-Neuanwendung (Ausbaustufe 4 Task 2).
KRITISCH (siehe Global Constraints des Plans): die .env wird im API-Container
als Einzeldatei-Bind-Mount eingehaengt. Ein solcher Mount folgt dem Inode -
die Datei MUSS in-place ueberschrieben werden (open r+/truncate unter flock),
NIEMALS ueber Temp-Datei+rename (das erzeugt einen neuen Inode und würde vom
Container nicht mehr gesehen).
"""
import fcntl
import json
import urllib.error
import urllib.request
from base64 import b64encode
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth import current_password_hash, hash_password, verify_password
from app.config import get_settings
from app.models.tables import Transaction
from app.services.categorize import apply_rules
def _sync_grafana_password(old: str, new: str, base_url: str) -> None:
"""PUT /api/admin/users/1/password gegen Grafana, Basic-Auth mit dem
ALTEN Passwort (admin:old) - Grafana authentifiziert den Request noch mit
dem bisherigen Passwort, aendert es aber auf `new`. Wird IMMER vor dem
Schreiben der .env aufgerufen: schlaegt Grafana fehl, bleibt die Datei
unangetastet (kein inkonsistenter Zwischenzustand GUI-Hash != Grafana)."""
url = f"{base_url.rstrip('/')}/api/admin/users/1/password"
auth_header = "Basic " + b64encode(f"admin:{old}".encode()).decode()
body = json.dumps({"password": new}).encode()
req = urllib.request.Request(
url, data=body, method="PUT",
headers={"Authorization": auth_header, "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
if resp.status >= 400:
raise RuntimeError(
f"Grafana-Passwortänderung fehlgeschlagen (HTTP {resp.status})."
)
except urllib.error.HTTPError as exc:
raise RuntimeError(
f"Grafana-Passwortänderung fehlgeschlagen (HTTP {exc.code})."
) from exc
except urllib.error.URLError as exc:
raise RuntimeError(
f"Grafana-Passwortänderung fehlgeschlagen: {exc.reason}"
) from exc
except (TimeoutError, OSError) as exc:
# Ein Hang in der Lesephase (nach erfolgreichem Verbindungsaufbau)
# kann ein rohes TimeoutError/socket.timeout werfen, das urlopen NICHT
# in ein URLError verpackt (URLError selbst ist zwar ein OSError, aber
# dieser Pfad faengt Faelle ab, die es nicht bis dorthin schaffen).
# Ohne diesen Fang wuerde die Exception bis in den Router durchschlagen
# und dort als 500 statt als deutsche 400-Fehlermeldung enden.
raise RuntimeError(
f"Grafana-Passwortänderung fehlgeschlagen: {exc}"
) from exc
def _rewrite_env_file(env_file: Path, new_password: str, new_hash: str) -> None:
"""Ersetzt FB_PASSWORD und FB_GUI_PASSWORD_HASH IN-PLACE (open r+ unter
flock, truncate) - der Inode der Datei bleibt unveraendert, siehe
Modul-Docstring."""
with open(env_file, "r+", encoding="utf-8") as f:
fcntl.flock(f, fcntl.LOCK_EX)
try:
lines = f.read().splitlines()
out = []
for line in lines:
if line.startswith("FB_PASSWORD="):
out.append(f"FB_PASSWORD='{new_password}'")
elif line.startswith("FB_GUI_PASSWORD_HASH="):
out.append(f"FB_GUI_PASSWORD_HASH='{new_hash}'")
else:
out.append(line)
f.seek(0)
f.write("\n".join(out) + "\n")
f.truncate()
finally:
fcntl.flock(f, fcntl.LOCK_UN)
def change_password(old: str, new: str) -> None:
"""Aendert das gemeinsame GUI-/Grafana-Passwort. Reihenfolge ist bindend:
1) altes Passwort gegen den aktuellen Hash verifizieren,
2) Grafana AKTUALISIEREN (bricht bei Fehler ab, ohne die .env
anzufassen),
3) erst danach die .env in-place neu schreiben.
"""
if not verify_password(old, current_password_hash()):
raise ValueError("Das alte Passwort ist falsch.")
settings = get_settings()
_sync_grafana_password(old, new, settings.grafana_url)
new_hash = hash_password(new)
_rewrite_env_file(settings.env_file, new, new_hash)
def apply_rules_retroactively(session: Session) -> int:
"""Wendet die aktuellen Kategorie-Regeln rueckwirkend auf alle
bestaetigten, noch unkategorisierten Buchungen an und committet."""
txs = session.execute(
select(Transaction).where(
Transaction.status == "confirmed",
Transaction.category_id.is_(None),
)
).scalars().all()
hits = apply_rules(session, txs)
session.commit()
return hits

View File

@@ -72,6 +72,14 @@ td.account {
border-radius: 4px;
}
.success {
background: #d4edda;
border: 1px solid #7bc088;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: 4px;
}
.total-balance {
font-size: 1.5rem;
font-weight: bold;

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Admin Finanzberatung{% endblock %}
{% block content %}
<h1>Admin</h1>
<section class="admin-section">
<h2>Passwort ändern</h2>
<p class="muted">Gilt gemeinsam für GUI- und Grafana-Login. Bestehende Sitzungen bleiben nach der Änderung gültig.</p>
{% if error %}<div class="warning">{{ error }}</div>{% endif %}
{% if success %}<div class="success">{{ success }}</div>{% endif %}
<form method="post" action="/admin/passwort">
<label>Altes Passwort
<input type="password" name="alt" required>
</label>
<label>Neues Passwort (mind. 8 Zeichen)
<input type="password" name="neu" minlength="8" required>
</label>
<label>Neues Passwort wiederholen
<input type="password" name="neu2" minlength="8" required>
</label>
<button type="submit">Passwort ändern</button>
</form>
</section>
<section class="admin-section">
<h2>Regeln neu anwenden</h2>
<p class="muted">Wendet die aktuellen Kategorie-Regeln rückwirkend auf noch nicht kategorisierte, bestätigte Buchungen an.</p>
<button type="button" hx-post="/api/category-rules/apply" hx-swap="none"
hx-on::after-request="handleApplyRulesResult(event)">
Regeln neu anwenden
</button>
<p id="apply-rules-result"></p>
</section>
<script>
function handleApplyRulesResult(event) {
var result = document.getElementById('apply-rules-result');
if (event.detail.successful) {
var data = JSON.parse(event.detail.xhr.responseText);
result.textContent = data.categorized + ' Buchung(en) neu kategorisiert.';
} else {
result.textContent = 'Fehler beim Anwenden der Regeln.';
}
}
</script>
{% endblock %}

View File

@@ -14,6 +14,7 @@
<a href="/buchungen">Buchungen</a>
<a href="/salden">Salden</a>
<a href="/planung">Planung</a>
<a href="/admin">Admin</a>
<a href="/hilfe">Hilfe</a>
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>
<form method="post" action="/logout">