Creator Workspace: Anmeldung, Sitzungen und Audit-Log
Erster funktionierender Login fuer /workspace. Bewusst OHNE neue Abhaengigkeiten: Node 24 bringt node:sqlite mit, Hashing und Zufall kommen aus node:crypto. Nichts zu kompilieren, keine fremde Lieferkette an einer Stelle, an der es um Zugangsdaten geht. Sicherheit: - Codes liegen nur als scrypt-Hash (N=32768) mit eigenem Salt in der DB - Vergleich in konstanter Zeit (timingSafeEqual) - Sitzungstoken 32 Byte Zufall, in der DB nur als SHA-256 - Cookie httpOnly, SameSite=lax, Path=/workspace, secure abhaengig von req.secure (live immer an, nur der lokale http-Test kommt ohne aus) - Sperre nach 8 Fehlversuchen je IP fuer 10 Minuten -- danach ist auch der richtige Code blockiert (geprueft) - gleiche Fehlermeldung bei falscher Rolle und falschem Code - Audit-Log fuer Anmeldung, Fehlversuch, Sperre, Abmeldung, Codewechsel Die Datenbank liegt AUSSERHALB des Repos (../workspace-daten/): sonst waere sie ueber express.static aus dem Netz erreichbar, und ein git pull wuerde Nutzdaten anfassen. workspace.js kann die Website nicht mitreissen: node:sqlite wird erst bei Bedarf per createRequire geladen, jede Route faengt ihre Fehler selbst ab. Faellt die DB aus, antwortet nur /workspace/api/* mit 503. Codes werden ausschliesslich auf der Kommandozeile erzeugt (server/workspace-code.js) und dort einmal angezeigt -- nie in Git. Ausserdem: start.html als geschuetzte Seite nach dem Anmelden. Der Schutz sitzt serverseitig vor express.static, nicht nur im Browser.
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
/* =====================================================================
|
||||
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();
|
||||
|
||||
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, req.ip || null,
|
||||
(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 = req.ip || "?";
|
||||
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: req.ip });
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
Reference in New Issue
Block a user