/* ===================================================================== workspace.js — Anmeldung und Sitzungen für den Creator Workspace (/workspace). Eigenständiger Router, nach dem Muster von gate.js und webdesign-gate.js. BEWUSST OHNE NEUE ABHÄNGIGKEITEN. Node 24 bringt `node:sqlite` mit, das Hashen und die Zufallswerte kommen aus `node:crypto`. Damit gibt es nichts zu kompilieren, nichts zu aktualisieren und keine fremde Lieferkette in einem Bereich, in dem es um Zugangsdaten geht. WICHTIG — dieses Modul darf die Website niemals mitreißen. Es läuft im selben Prozess wie dogfather-universe.com. Deshalb: * beim Laden wird KEINE Datenbank geöffnet (siehe `db()` weiter unten), * jede Route fängt ihre Fehler selbst ab, * schlägt die Datenbank fehl, antwortet nur /workspace/api/* mit 503 — die restliche Seite merkt davon nichts. Sicherheitsentscheidungen und ihre Gründe stehen jeweils direkt an der betreffenden Stelle. ===================================================================== */ import express from "express"; import { randomBytes, scryptSync, timingSafeEqual, createHash, } from "node:crypto"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { mkdirSync } from "node:fs"; import { createRequire } from "node:module"; const __dirname = dirname(fileURLToPath(import.meta.url)); /* `node:sqlite` wird bewusst NICHT oben importiert, sondern erst in db() nachgeladen. Ein fehlgeschlagener Import an dieser Stelle würde den gesamten Website-Prozess beim Start abbrechen -- also auch dogfather-universe.com. So bleibt der Schaden auf /workspace/api/ beschränkt. (Vorhanden ab Node 22; auf dem Server läuft Node 24.) */ const require = createRequire(pathToFileURL(__dirname + "/")); /* Die Datenbank liegt bewusst AUSSERHALB des Repo-Ordners. Zwei Gründe: express.static liefert den Repo-Ordner aus (eine .db darin wäre über das Netz erreichbar), und ein `git pull` darf Nutzdaten nie anfassen. */ const DB_PFAD = process.env.WORKSPACE_DB || join(__dirname, "..", "..", "workspace-daten", "workspace.db"); const COOKIE = "dfw_sitzung"; const SITZUNG_STUNDEN = 12; const VERSUCHE_MAX = 8; // pro IP const VERSUCHE_FENSTER_MIN = 10; const ROLLEN = new Set(["admin", "creator", "scout"]); /* scrypt-Parameter. N=2^15 braucht auf diesem Server rund 150 ms — spürbar genug, um Rateversuche auszubremsen, und unauffällig für einen echten Anmeldevorgang. Die Werte werden je Datensatz mitgespeichert, damit sie später erhöht werden können, ohne alte Codes ungültig zu machen. */ const SCRYPT = { N: 32768, r: 8, p: 1, keylen: 64 }; let _db = null; let _dbFehler = null; function db() { if (_db) return _db; if (_dbFehler) throw _dbFehler; try { const { DatabaseSync } = require("node:sqlite"); mkdirSync(dirname(DB_PFAD), { recursive: true }); const d = new DatabaseSync(DB_PFAD); d.exec(` PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS personen ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, rolle TEXT NOT NULL CHECK (rolle IN ('admin','creator','scout')), code_hash TEXT NOT NULL, code_salt TEXT NOT NULL, code_n INTEGER NOT NULL, aktiv INTEGER NOT NULL DEFAULT 1, erstellt TEXT NOT NULL, letzter_login TEXT ); CREATE TABLE IF NOT EXISTS sitzungen ( token_hash TEXT PRIMARY KEY, person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, erstellt TEXT NOT NULL, gueltig_bis TEXT NOT NULL, ip TEXT, browser TEXT ); /* Audit-Log. Im Konzept ausdrücklich gefordert ("Login-Historie, kritische Aktionen protokollieren"). Nur anhängen, nie ändern. */ CREATE TABLE IF NOT EXISTS protokoll ( id INTEGER PRIMARY KEY AUTOINCREMENT, zeitpunkt TEXT NOT NULL, person_id INTEGER, rolle TEXT, aktion TEXT NOT NULL, detail TEXT, ip TEXT ); CREATE TABLE IF NOT EXISTS versuche ( ip TEXT NOT NULL, zeitpunkt TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_versuche_ip ON versuche (ip, zeitpunkt); CREATE INDEX IF NOT EXISTS idx_sitzungen_gueltig ON sitzungen (gueltig_bis); `); _db = d; return d; } catch (fehler) { _dbFehler = fehler; console.error("[workspace] Datenbank nicht verfügbar:", fehler?.message); throw fehler; } } /* ---------- Hilfsmittel ---------------------------------------------- */ const jetzt = () => new Date().toISOString(); /* Die echte Besucher-IP. Warum das nötig ist: Die Kette lautet Besucher -> Cloudflare -> Caddy -> Express. `trust proxy` steht auf 1, Express nimmt daher den letzten Eintrag aus X-Forwarded-For -- und das ist die Adresse von Cloudflare, nicht die des Besuchers. Folge: Im Protokoll stand bei jedem dieselbe IP, und viel schlimmer -- ALLE Nutzer teilten sich einen einzigen Sperr-Zähler. Ein paar Tippfehler von drei Leuten hätten den Zugang für alle gesperrt. Cloudflare setzt die echte Adresse in CF-Connecting-IP und überschreibt den Kopf bei jeder Anfrage, ein Besucher kann ihn also nicht fälschen. Einschränkung: Wer die Adresse des Servers kennt und ihn unter Umgehung von Cloudflare direkt anspricht, könnte den Kopf frei setzen. Deshalb wird auf die Peer-Adresse zurückgefallen, sobald der Kopf fehlt -- und beide Werte landen im Protokoll, damit so etwas auffällt. */ function echteIp(req) { const cf = req.get("cf-connecting-ip"); if (cf && cf.length <= 45) return cf.trim(); return req.ip || "?"; } function hashe(code, salt, N) { return scryptSync(code, salt, SCRYPT.keylen, { N, r: SCRYPT.r, p: SCRYPT.p, maxmem: 256 * 1024 * 1024 }).toString("hex"); } /* Vergleich in konstanter Zeit. Ein normaler ===-Vergleich bricht beim ersten abweichenden Zeichen ab; aus den Laufzeitunterschieden lässt sich ein Geheimnis Zeichen für Zeichen erraten. */ function gleich(a, b) { const x = Buffer.from(String(a), "utf8"); const y = Buffer.from(String(b), "utf8"); if (x.length !== y.length) return false; return timingSafeEqual(x, y); } const tokenHash = (t) => createHash("sha256").update(t).digest("hex"); function protokolliere(aktion, { personId = null, rolle = null, detail = null, ip = null } = {}) { try { db().prepare( "INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip) VALUES (?,?,?,?,?,?)" ).run(jetzt(), personId, rolle, aktion, detail, ip); } catch { /* Ein fehlgeschlagener Protokolleintrag darf nichts blockieren. */ } } function zuVieleVersuche(ip) { const grenze = new Date(Date.now() - VERSUCHE_FENSTER_MIN * 60_000).toISOString(); db().prepare("DELETE FROM versuche WHERE zeitpunkt < ?").run(grenze); const { anzahl } = db() .prepare("SELECT COUNT(*) AS anzahl FROM versuche WHERE ip = ? AND zeitpunkt >= ?") .get(ip, grenze); return anzahl >= VERSUCHE_MAX; } /* ---------- Sitzung ---------------------------------------------------- */ export function sitzungLesen(req) { const token = req.cookies?.[COOKIE]; if (!token) return null; try { const reihe = db().prepare(` SELECT s.gueltig_bis, p.id, p.name, p.rolle, p.aktiv FROM sitzungen s JOIN personen p ON p.id = s.person_id WHERE s.token_hash = ?`).get(tokenHash(token)); if (!reihe || !reihe.aktiv) return null; if (reihe.gueltig_bis < jetzt()) { db().prepare("DELETE FROM sitzungen WHERE token_hash = ?").run(tokenHash(token)); return null; } return { id: reihe.id, name: reihe.name, rolle: reihe.rolle }; } catch { return null; } } function sitzungSetzen(res, person, req) { const token = randomBytes(32).toString("hex"); const bis = new Date(Date.now() + SITZUNG_STUNDEN * 3600_000).toISOString(); db().prepare( "INSERT INTO sitzungen (token_hash, person_id, erstellt, gueltig_bis, ip, browser) VALUES (?,?,?,?,?,?)" ).run(tokenHash(token), person.id, jetzt(), bis, echteIp(req), (req.get("user-agent") || "").slice(0, 200)); res.cookie(COOKIE, token, { httpOnly: true, // kein Zugriff aus JavaScript -> kein Diebstahl per XSS /* Live immer true: Hinter Caddy ist req.secure dank `trust proxy` das Ergebnis von X-Forwarded-Proto. Nur beim lokalen Test über http fällt es weg -- sonst wäre der Ablauf gar nicht prüfbar, weil Browser und curl ein Secure-Cookie über http verwerfen. */ secure: req.secure, sameSite: "lax", // schützt vor fremden Formularen (CSRF) path: "/workspace", // gilt nur hier, nicht auf der ganzen Domain maxAge: SITZUNG_STUNDEN * 3600_000, }); } /* ---------- Router ----------------------------------------------------- */ export const workspaceRouter = express.Router(); /* Schutz der angemeldeten Seiten. Serverseitig, nicht nur im Browser -- sonst könnte man die Seite einfach direkt aufrufen. */ const GESCHUETZT = ["/workspace/start.html"]; workspaceRouter.use((req, res, next) => { if (!GESCHUETZT.includes(req.path)) return next(); const person = sitzungLesen(req); if (person) return next(); return res.redirect(302, "/workspace/"); }); workspaceRouter.post("/workspace/api/anmelden", (req, res) => { const ip = echteIp(req); try { if (zuVieleVersuche(ip)) { protokolliere("anmeldung_gesperrt", { ip }); return res.status(429).json({ fehler: "zu_viele_versuche" }); } const rolle = String(req.body?.rolle || ""); const code = String(req.body?.code || ""); if (!ROLLEN.has(rolle) || code.length < 4 || code.length > 200) { db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt()); return res.status(401).json({ fehler: "ungueltig" }); } /* Alle aktiven Personen dieser Rolle durchgehen. Es gibt bewusst KEIN Benutzerfeld: Der Code allein identifiziert die Person. Bei der erwarteten Größenordnung (eine Handvoll Personen je Rolle) ist das unkritisch; ab etwa 50 Codes je Rolle sollte hier ein Präfix im Code die Vorauswahl übernehmen, sonst wird die Anmeldung spürbar langsam. */ const kandidaten = db() .prepare("SELECT id, name, rolle, code_hash, code_salt, code_n FROM personen WHERE rolle = ? AND aktiv = 1") .all(rolle); let gefunden = null; for (const k of kandidaten) { if (gleich(hashe(code, k.code_salt, k.code_n), k.code_hash)) { gefunden = k; break; } } if (!gefunden) { db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt()); protokolliere("anmeldung_fehlgeschlagen", { rolle, ip }); /* Bewusst dieselbe Antwort wie bei falscher Rolle: Wer raten will, soll nicht erfahren, ob wenigstens die Rolle gestimmt hat. */ return res.status(401).json({ fehler: "ungueltig" }); } db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip); db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), gefunden.id); sitzungSetzen(res, gefunden, req); protokolliere("anmeldung", { personId: gefunden.id, rolle, ip, detail: gefunden.name }); return res.json({ weiter: "/workspace/start.html", name: gefunden.name, rolle }); } catch (fehler) { console.error("[workspace] Anmeldung fehlgeschlagen:", fehler?.message); return res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); workspaceRouter.post("/workspace/api/abmelden", (req, res) => { try { const token = req.cookies?.[COOKIE]; if (token) { const person = sitzungLesen(req); db().prepare("DELETE FROM sitzungen WHERE token_hash = ?").run(tokenHash(token)); if (person) protokolliere("abmeldung", { personId: person.id, rolle: person.rolle, ip: echteIp(req) }); } } catch { /* Abmelden darf nie scheitern. */ } res.clearCookie(COOKIE, { path: "/workspace" }); res.json({ ok: true }); }); workspaceRouter.get("/workspace/api/ich", (req, res) => { const person = sitzungLesen(req); if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); res.json({ name: person.name, rolle: person.rolle }); }); /* ---------- Verwaltung (nur über die Kommandozeile) -------------------- Wird von workspace-code.js benutzt. Bewusst nicht über das Netz erreichbar: Codes werden auf dem Server erzeugt, einmal angezeigt und nie gespeichert -- weder in Git noch in einer Notiz. */ /* Alphabet ohne 0/O und 1/I/l: Diese Codes werden abgetippt und weitergegeben, Verwechslungen kosten sonst unnötig Nerven. */ const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; export function codeErzeugen(gruppen = 4, laenge = 4) { const roh = randomBytes(gruppen * laenge); let aus = ""; for (let i = 0; i < gruppen * laenge; i++) { if (i && i % laenge === 0) aus += "-"; aus += ALPHABET[roh[i] % ALPHABET.length]; } return aus; } export function personAnlegen(name, rolle) { if (!ROLLEN.has(rolle)) throw new Error(`Unbekannte Rolle: ${rolle}`); const code = codeErzeugen(); const salt = randomBytes(16).toString("hex"); const hash = hashe(code, salt, SCRYPT.N); const { lastInsertRowid } = db().prepare( "INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)" ).run(name, rolle, hash, salt, SCRYPT.N, jetzt()); protokolliere("person_angelegt", { personId: Number(lastInsertRowid), rolle, detail: name }); return { id: Number(lastInsertRowid), name, rolle, code }; } export function codeNeu(id) { const person = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(id); if (!person) throw new Error(`Keine Person mit Nummer ${id}`); const code = codeErzeugen(); const salt = randomBytes(16).toString("hex"); db().prepare("UPDATE personen SET code_hash = ?, code_salt = ?, code_n = ? WHERE id = ?") .run(hashe(code, salt, SCRYPT.N), salt, SCRYPT.N, id); /* Alle offenen Sitzungen beenden -- ein neuer Code soll den alten Zugang wirklich beenden, nicht nur die nächste Anmeldung betreffen. */ db().prepare("DELETE FROM sitzungen WHERE person_id = ?").run(id); protokolliere("code_erneuert", { personId: id, rolle: person.rolle, detail: person.name }); return { ...person, code }; } export function personSperren(id, aktiv = 0) { db().prepare("UPDATE personen SET aktiv = ? WHERE id = ?").run(aktiv ? 1 : 0, id); if (!aktiv) db().prepare("DELETE FROM sitzungen WHERE person_id = ?").run(id); protokolliere(aktiv ? "person_entsperrt" : "person_gesperrt", { personId: id }); } export function personenListe() { return db().prepare(` SELECT p.id, p.name, p.rolle, p.aktiv, p.erstellt, p.letzter_login, (SELECT COUNT(*) FROM sitzungen s WHERE s.person_id = p.id) AS sitzungen FROM personen p ORDER BY p.rolle, p.name`).all(); } export function protokollLesen(anzahl = 20) { return db().prepare( "SELECT zeitpunkt, rolle, aktion, detail, ip FROM protokoll ORDER BY id DESC LIMIT ?" ).all(anzahl); }