Grotti 2.2.0: Auth, API-Tokens, MCP und Doku-Trennung.

This commit is contained in:
Dominic Gundel committed 2026-10-15 10:53:12 +02:00
1 parent b20f8a7779
commit 5a3716b26a
65 files changed
+2212 -198

No files matched your search

+426 -36
View File
@@ -3,6 +3,7 @@
import json
import re
import secrets
import time
import uuid
from datetime import datetime
from pathlib import Path
@@ -10,10 +11,11 @@ from urllib.parse import quote
import markdown
import psycopg
from flask import Flask, abort, g, jsonify, render_template, request
from flask import Flask, abort, g, jsonify, redirect, render_template, request, session, url_for
from markupsafe import Markup
from psycopg.rows import dict_row
from grotti import auth as grotti_auth
from grotti import config as grotti_config
from grotti import eject, jobs, library, moonraker, notify, store
from grotti.catalog import color_catalog
@@ -29,7 +31,6 @@ from grotti.watcher import StateWatcher
ROOT = Path(__file__).resolve().parent
TOOL_ROOT = ROOT.parent
DOCS_DIR = TOOL_ROOT / "docs"
CHANGELOG = TOOL_ROOT / "CHANGELOG.md"
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
VERDICTS = {"", "gut", "ausschuss"}
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
@@ -47,8 +48,13 @@ def create_app(cfg):
static_folder=str(ROOT / "static"),
)
app.config["GROTTI"] = cfg
app.config["SECRET_KEY"] = cfg.get("secret_key") or secrets.token_hex(32)
app.config["TEMPLATES_AUTO_RELOAD"] = True
app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
if cfg.get("fqdn", "").startswith("https://"):
app.config["SESSION_COOKIE_SECURE"] = True
app.json.ensure_ascii = False
app.extensions["send_queue"] = SendQueue(cfg["database_url"], cfg)
watcher = StateWatcher(cfg["database_url"], cfg)
@@ -58,19 +64,167 @@ def create_app(cfg):
watcher.start()
dispatcher.start()
@app.before_request
def check_token():
token = cfg["token"]
if not token or not request.path.startswith("/api/"):
PUBLIC_PATHS = {
"/login",
"/setup",
"/api/v1/auth/status",
"/api/v1/auth/login",
"/api/v1/auth/setup",
"/api/v1/version",
}
ADMIN_API_PREFIXES = (
"/api/v1/users",
"/api/v1/einstellungen",
)
ADMIN_API_EXACT = {
"/api/v1/telegram/test",
}
ADMIN_METHODS_ON = {
# Drucker anlegen/ändern/löschen nur Admin
("POST", "/api/v1/drucker"),
}
def conn():
if "db" not in g:
g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row)
return g.db
def ensure_csrf():
if not session.get("csrf"):
session["csrf"] = grotti_auth.new_csrf()
return session["csrf"]
def auth_user():
return getattr(g, "user", None)
def require_role(need):
user = auth_user()
if not user or not grotti_auth.role_at_least(user.get("role"), need):
raise GrottiError("Keine Berechtigung.", "forbidden", 403)
return user
def resolve_bearer(presented):
legacy = cfg.get("token") or ""
if legacy and secrets.compare_digest(presented, legacy):
return {
"id": None,
"username": "legacy-token",
"role": "admin",
"auth": "legacy",
}
if not presented.startswith(grotti_auth.TOKEN_PREFIX):
return None
row = store.get_api_token_by_hash(conn(), grotti_auth.hash_token(presented))
if not row or row.get("revoked_at") or not row.get("user_active"):
return None
if row.get("expires_at") and row["expires_at"].timestamp() < time.time():
return None
store.touch_api_token(conn(), row["id"])
return {
"id": row["user_id"],
"username": row["username"],
"role": row["role"],
"auth": "token",
"token_id": row["id"],
}
def resolve_session_user():
uid = session.get("user_id")
if not uid:
return None
row = store.get_user(conn(), uid)
if not row or not row.get("active"):
session.clear()
return None
return {
"id": row["id"],
"username": row["username"],
"role": row["role"],
"auth": "session",
}
def wants_json():
if request.path.startswith("/api/"):
return True
accept = request.accept_mimetypes
return accept.best == "application/json"
@app.before_request
def check_auth():
g.user = None
path = request.path
if path.startswith("/static/"):
return None
if path in PUBLIC_PATHS:
return None
users_exist = store.user_count(conn()) > 0
if not users_exist:
if path.startswith("/api/") and path != "/api/v1/auth/setup":
if path == "/api/v1/auth/status":
return None
return jsonify({
"error": "Zuerst Admin anlegen.",
"code": "setup",
"setup": True,
}), 503
if path != "/setup":
return redirect(url_for("setup_page"))
return None
presented = ""
header = request.headers.get("Authorization", "")
if header.startswith("Bearer "):
presented = header[7:]
elif request.method == "GET":
presented = request.args.get("token", "")
if not presented or not secrets.compare_digest(presented, token):
return jsonify({"error": "Token fehlt oder stimmt nicht.", "code": "token"}), 401
presented = header[7:].strip()
elif request.method == "GET" and request.path.startswith("/api/"):
presented = (request.args.get("token") or "").strip()
user = None
if presented:
user = resolve_bearer(presented)
if not user:
return jsonify({"error": "Token fehlt oder stimmt nicht.", "code": "token"}), 401
else:
user = resolve_session_user()
if not user:
if wants_json() or path.startswith("/api/"):
return jsonify({"error": "Anmeldung nötig.", "code": "auth"}), 401
return redirect(url_for("login_page", next=path))
g.user = user
ensure_csrf()
# CSRF für Cookie-Sessions bei mutierenden API-Calls
if (
user.get("auth") == "session"
and path.startswith("/api/")
and request.method in ("POST", "PATCH", "PUT", "DELETE")
):
sent = request.headers.get("X-CSRF-Token") or ""
expected = session.get("csrf") or ""
if not expected or not secrets.compare_digest(sent, expected):
return jsonify({"error": "CSRF-Token fehlt oder ungültig.", "code": "csrf"}), 403
need_admin = False
if path.startswith(ADMIN_API_PREFIXES) or path in ADMIN_API_EXACT:
if path == "/api/v1/einstellungen" and request.method == "GET":
need_admin = False
elif path == "/api/v1/einstellungen" and request.method == "PATCH":
# Operator darf nur auto_queue schalten
payload = request.get_json(silent=True) or {}
keys = {k for k, v in payload.items() if v is not None}
need_admin = keys - {"auto_queue"} != set()
elif path.startswith("/api/v1/users"):
need_admin = True
elif request.method != "GET":
need_admin = True
if (request.method, path) in ADMIN_METHODS_ON:
need_admin = True
if path.startswith("/api/v1/drucker/id/") and request.method in ("PATCH", "DELETE"):
need_admin = True
if need_admin and not grotti_auth.role_at_least(user.get("role"), "admin"):
return jsonify({"error": "Nur für Admins.", "code": "forbidden"}), 403
return None
@app.teardown_appcontext
@@ -88,16 +242,11 @@ def create_app(cfg):
@app.errorhandler(GrottiError)
def grotti_error(err):
conn = g.get("db")
if conn:
conn.rollback()
db = g.get("db")
if db:
db.rollback()
return jsonify({"error": err.message, "code": err.code}), err.status
def conn():
if "db" not in g:
g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row)
return g.db
def body():
data = request.get_json(silent=True)
if not isinstance(data, dict):
@@ -124,16 +273,250 @@ def create_app(cfg):
return host
@app.context_processor
def inject_version():
return {"version": __version__}
def inject_globals():
user = auth_user()
return {
"version": __version__,
"csrf": session.get("csrf") or "",
"current_user": user,
}
def page(name, title):
return render_template(name, page=name.split(".")[0], title=title, token=cfg["token"])
return render_template(
name,
page=name.split(".")[0],
title=title,
token="",
)
def audit(kind, message, host=""):
user = auth_user()
who = (user or {}).get("username") or "?"
store.add_event(conn(), host, "", kind, "%s · %s" % (who, message))
@app.get("/login")
def login_page():
if store.user_count(conn()) == 0:
return redirect(url_for("setup_page"))
if resolve_session_user():
return redirect("/")
ensure_csrf()
return render_template("login.html", title="Anmelden", csrf=session.get("csrf") or "")
@app.get("/setup")
def setup_page():
if store.user_count(conn()) > 0:
return redirect(url_for("login_page"))
ensure_csrf()
return render_template("setup.html", title="Ersten Admin anlegen", csrf=session.get("csrf") or "")
@app.get("/api/v1/auth/status")
def auth_status():
count = store.user_count(conn())
user = resolve_session_user()
return jsonify({
"setup_required": count == 0,
"authenticated": bool(user or auth_user()),
"user": grotti_auth.public_user(user) if user else None,
"oauth_enabled": bool((cfg.get("oauth") or {}).get("enabled")),
"legacy_token_set": bool(cfg.get("token")),
})
@app.post("/api/v1/auth/setup")
def auth_setup():
if store.user_count(conn()) > 0:
raise GrottiError("Setup ist schon erledigt.", "setup", 409)
data = body()
username = grotti_auth.validate_username(data.get("username") or "")
password = grotti_auth.validate_password(data.get("password") or "")
row = store.create_user(conn(), username, grotti_auth.hash_password(password), "admin")
store.touch_user_login(conn(), row["id"])
session.clear()
session["user_id"] = row["id"]
session["csrf"] = grotti_auth.new_csrf()
store.add_event(conn(), "", "", "auth", "Admin %s angelegt" % username)
return jsonify({"user": grotti_auth.public_user(row), "csrf": session["csrf"]}), 201
@app.post("/api/v1/auth/login")
def auth_login():
if store.user_count(conn()) == 0:
raise GrottiError("Zuerst Admin anlegen.", "setup", 503)
key = request.remote_addr or "unknown"
if not grotti_auth.login_rate_ok(key):
raise GrottiError("Zu viele Versuche. Bitte warten.", "rate", 429)
data = body()
username = (data.get("username") or "").strip()
password = data.get("password") or ""
row = store.get_user_by_name(conn(), username)
if not row or not row.get("active") or not grotti_auth.verify_password(row["password_hash"], password):
grotti_auth.login_rate_hit(key)
raise GrottiError("Benutzer oder Passwort falsch.", "auth", 401)
store.touch_user_login(conn(), row["id"])
session.clear()
session["user_id"] = row["id"]
session["csrf"] = grotti_auth.new_csrf()
store.add_event(conn(), "", "", "auth", "Login %s" % row["username"])
return jsonify({"user": grotti_auth.public_user(row), "csrf": session["csrf"]})
@app.post("/api/v1/auth/logout")
def auth_logout():
session.clear()
return jsonify({"ok": True})
@app.get("/api/v1/me")
def me():
user = require_role("operator")
return jsonify({"user": {
"id": user["id"],
"username": user["username"],
"role": user["role"],
"auth": user.get("auth"),
}, "csrf": ensure_csrf()})
@app.get("/api/v1/users")
def users_list():
require_role("admin")
return jsonify([grotti_auth.public_user(r) for r in store.list_users(conn())])
@app.post("/api/v1/users")
def users_create():
require_role("admin")
data = body()
username = grotti_auth.validate_username(data.get("username") or "")
password = grotti_auth.validate_password(data.get("password") or "")
role = grotti_auth.normalize_role(data.get("role") or "operator")
if store.get_user_by_name(conn(), username):
raise GrottiError("Benutzer gibt es schon.", "user", 409)
row = store.create_user(conn(), username, grotti_auth.hash_password(password), role)
audit("auth", "Benutzer %s (%s) angelegt" % (username, role))
return jsonify(grotti_auth.public_user(row)), 201
@app.patch("/api/v1/users/<int:user_id>")
def users_patch(user_id):
actor = require_role("admin")
data = body()
row = store.get_user(conn(), user_id)
if not row:
raise GrottiError("Benutzer fehlt.", "user", 404)
password_hash = None
if data.get("password"):
password_hash = grotti_auth.hash_password(grotti_auth.validate_password(data["password"]))
role = grotti_auth.normalize_role(data["role"]) if "role" in data else None
active = data.get("active") if "active" in data else None
if active is False and row["id"] == actor.get("id"):
raise GrottiError("Eigenes Konto nicht deaktivieren.", "user")
if role == "operator" and row["role"] == "admin" and row["id"] == actor.get("id"):
admins = [u for u in store.list_users(conn()) if u["role"] == "admin" and u["active"]]
if len(admins) <= 1:
raise GrottiError("Letzter Admin bleibt Admin.", "user")
updated = store.update_user(conn(), user_id, password_hash=password_hash, role=role, active=active)
audit("auth", "Benutzer %s geändert" % row["username"])
return jsonify(grotti_auth.public_user(updated))
@app.delete("/api/v1/users/<int:user_id>")
def users_delete(user_id):
actor = require_role("admin")
row = store.get_user(conn(), user_id)
if not row:
raise GrottiError("Benutzer fehlt.", "user", 404)
if row["id"] == actor.get("id"):
raise GrottiError("Eigenes Konto nicht löschen.", "user")
if row["role"] == "admin":
admins = [u for u in store.list_users(conn()) if u["role"] == "admin" and u["active"]]
if len(admins) <= 1:
raise GrottiError("Letzter Admin bleibt.", "user")
store.delete_user(conn(), user_id)
audit("auth", "Benutzer %s gelöscht" % row["username"])
return "", 204
@app.get("/api/v1/tokens")
def tokens_list():
user = require_role("operator")
if user.get("role") == "admin" and request.args.get("all") == "1":
rows = store.list_api_tokens(conn(), user_id=None)
else:
if not user.get("id"):
return jsonify([])
rows = store.list_api_tokens(conn(), user_id=user["id"])
out = []
for row in rows:
item = grotti_auth.public_token(row)
item["username"] = row.get("username")
out.append(item)
return jsonify(out)
@app.post("/api/v1/tokens")
def tokens_create():
user = require_role("operator")
if not user.get("id"):
raise GrottiError("Legacy-Token: bitte Benutzer-Token anlegen.", "token")
data = body()
name = (data.get("name") or "Token").strip()[:80]
role = grotti_auth.clamp_token_role(user["role"], data.get("role"))
raw = grotti_auth.new_api_token()
row = store.create_api_token(
conn(),
user["id"],
name,
grotti_auth.hash_token(raw),
grotti_auth.token_prefix(raw),
role,
)
audit("auth", "API-Token „%s“ erzeugt" % name)
item = grotti_auth.public_token(row)
item["token"] = raw
return jsonify(item), 201
@app.delete("/api/v1/tokens/<int:token_id>")
def tokens_revoke(token_id):
user = require_role("operator")
if user.get("role") == "admin":
row = store.revoke_api_token(conn(), token_id, user_id=None)
else:
row = store.revoke_api_token(conn(), token_id, user_id=user.get("id"))
if not row:
raise GrottiError("Token fehlt.", "token", 404)
audit("auth", "API-Token #%s widerrufen" % token_id)
return jsonify(grotti_auth.public_token(row))
def doc_names():
"""Nur Anleitung-*.md — Technik liegt unter docs/internal/ (nicht in der App)."""
if not DOCS_DIR.is_dir():
return []
return sorted(p.stem for p in DOCS_DIR.glob("*.md"))
return sorted(p.stem for p in DOCS_DIR.glob("Anleitung-*.md"))
ANLEITUNG_ORDER = (
"Anleitung-Einrichtung",
"Anleitung-Oberflaeche",
"Anleitung-Wand",
"Anleitung-Dateien",
"Anleitung-ACE",
"Anleitung-Warteschlange",
"Anleitung-Material",
"Anleitung-Einstellungen",
"Anleitung-Stoerungen",
)
def anleitung_list(names=None):
names = names if names is not None else doc_names()
anleitung_set = set(names)
ordered = [n for n in ANLEITUNG_ORDER if n in anleitung_set]
ordered += sorted(anleitung_set - set(ordered))
return ordered
def anleitung_title(name):
labels = {
"Anleitung-Einrichtung": "Einrichtung",
"Anleitung-Oberflaeche": "Oberfläche",
"Anleitung-Wand": "Druckerwand",
"Anleitung-Dateien": "Dateien senden",
"Anleitung-ACE": "ACE und Spulen",
"Anleitung-Warteschlange": "Warteschlange",
"Anleitung-Material": "Material und mehr",
"Anleitung-Einstellungen": "Einstellungen",
"Anleitung-Stoerungen": "Störungen",
}
return labels.get(name, name.replace("Anleitung-", "").replace("-", " "))
def render_markdown(path):
text = path.read_text(encoding="utf-8")
@@ -180,7 +563,7 @@ def create_app(cfg):
"etikett.html",
page="etikett",
title="Etikett %s" % spool["chargen_nr"],
token=cfg["token"],
token="",
spool=spool,
qr_url=qr_url,
)
@@ -203,37 +586,42 @@ def create_app(cfg):
@app.get("/doku")
def doku():
names = doc_names()
anleitung = anleitung_list(names)
return render_template(
"doku.html",
page="doku",
title="Doku",
token=cfg["token"],
docs=doc_names(),
token="",
docs=names,
anleitung=anleitung,
anleitung_title=anleitung_title,
)
@app.get("/doku/<name>")
def doku_page(name):
if name == "changelog":
path, title = CHANGELOG, "Changelog"
elif name in doc_names():
path, title = DOCS_DIR / ("%s.md" % name), name.replace("-", " ")
else:
names = doc_names()
if name not in names:
abort(404)
path = DOCS_DIR / ("%s.md" % name)
if not path.is_file():
abort(404)
anleitung = anleitung_list(names)
return render_template(
"doku_page.html",
page="doku",
title=title,
token=cfg["token"],
title=anleitung_title(name),
token="",
content=render_markdown(path),
docs=doc_names(),
docs=names,
anleitung=anleitung,
anleitung_title=anleitung_title,
current=name,
)
@app.get("/api/v1/version")
def version_info():
return jsonify({"version": __version__, "changelog": "/doku/changelog"})
return jsonify({"version": __version__})
@app.get("/api/v1/einstellungen")
def settings():
@@ -262,7 +650,7 @@ def create_app(cfg):
return jsonify({"ok": True})
@app.get("/api/v1/drucker")
@app.get("/api/drucker")
@app.get("/api/drucker") # veraltet — Alias zu /api/v1/drucker
def printers():
rows = store.list_printers(conn())
clears = store.bed_clear_map(conn())
@@ -767,6 +1155,8 @@ def create_app(cfg):
data = body()
if isinstance(data.get("steps"), list) and data.get("steps"):
return jsonify(enqueue_ablauf_payload(data)), 202
if not bool(data.get("confirm")):
raise GrottiError("Bestätigung fehlt.", "bestaetigung")
try:
file_id = int(data.get("file_id"))
except (TypeError, ValueError) as exc: