diff --git a/server/pruef-angemeldet-bleiben.mjs b/server/pruef-angemeldet-bleiben.mjs new file mode 100644 index 00000000..7ca0c639 --- /dev/null +++ b/server/pruef-angemeldet-bleiben.mjs @@ -0,0 +1,246 @@ +/* ===================================================================== + ANGEMELDET BLEIBEN, BIS MAN SICH SELBST ABMELDET (06.10.2026) + ===================================================================== + + Filipe: „ich will dass wenn sich jemand bei der workspace seite + anmeldet, angemeldet bleibt bis er sich selbst abmeldet." + + --------------------------------------------------------------------- + WAS DIE MESSUNG VORHER ERGEBEN HAT -- und warum die naheliegende + Antwort die falsche gewesen waere + + Die naheliegende Antwort waere gewesen, die Sitzung zu verlaengern. + Sie war aber nie zu kurz: + + * Der Keks gilt 180 Tage und verlaengert sich beim Benutzen. + * In der echten Datenbank lief KEINE der 28 Sitzungen vor Maerz + 2027 ab. + * Am echten Browser: Er uebersteht Neuladen, den Service Worker + und das Schliessen des Browsers. + + Trotzdem meldeten sich dieselben Geraete staendig neu an -- + dieselbe IP, dieselbe Browserkennung, einmal zweimal innerhalb + einer Minute. Der Grund lag woanders: `/workspace/` zeigte die + Anmeldung, OHNE zu fragen, ob schon jemand angemeldet ist. Das ist + die Adresse, die man tippt und weitergibt. + + Haette ich die Sitzungsdauer erhoeht, waere der Fehler geblieben + und die Sicherheit schlechter geworden. + + --------------------------------------------------------------------- + DIE WICHTIGSTE PRUEFUNG IST DIE ZAHL DER SITZUNGEN + + „Man bleibt angemeldet" laesst sich leicht vortaeuschen: Wer bei + jedem Besuch stillschweigend neu anmeldet, sieht auch nie ein + Codefeld. Deshalb wird hier durchgehend gezaehlt, wie viele + Sitzungen in der Datenbank stehen. Bleibt es bei EINER, ist + niemand neu angemeldet worden. + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-ang-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +import { notbremse } from "./helfer-notbremse.mjs"; +const { eigenerPort } = await import("./helfer-port.mjs"); +const PORT = await eigenerPort(import.meta, "die Anmeldepruefung"); + +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { + return ec.call(this, n, w, { ...(o || {}), secure: false }); +}; +await import("./index.js"); +notbremse(240_000, "pruef-angemeldet-bleiben"); +await new Promise((r) => setTimeout(r, 900)); + +let fehler = 0, geprueft = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +function braucht(bedingung, was) { + if (bedingung) return; + console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}\n`); + try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + process.exit(2); +} + +/* ---------- Ein Zugang ------------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const kenn = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", kenn, new Date().toISOString()); +const CODE = "CODE-BLEIB-0001"; +const salz = randomBytes(16).toString("hex"); +const h = scryptSync(CODE, salz, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); +d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n," + + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") + .run("Bleibt", "scout", h, salz, 32768, + createHmac("sha256", kenn).update(CODE).digest("hex"), "2025-10-01T08:00:00Z"); +d.close(); + +const sitzungen = () => { + const db2 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); + const n = db2.prepare("SELECT COUNT(*) n FROM sitzungen").get().n; + db2.close(); + return n; +}; + +/* ---------- Ein Browser MIT Profil auf der Platte ----------------- + Nur so ueberlebt ein Keks das Schliessen des Browsers. Ein + gewoehnlicher Kontext vergisst alles -- dann maesse diese Pruefung + ihre eigene Einrichtung statt der Software. */ +const pw = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const profil = join(ordner, "profil"); +const BASIS = `http://127.0.0.1:${PORT}`; +const auf = () => pw.chromium.launchPersistentContext(profil, { + viewport: { width: 412, height: 800 }, +}); + +/** Wohin fuehrt diese Adresse -- und ist die Seite danach sichtbar? */ +async function hin(seite, pfad) { + await seite.goto(BASIS + pfad, { waitUntil: "networkidle" }); + /* Laenger als die feste Frist im Skript (900 ms): Sonst misst diese + Pruefung den Zwischenzustand und nicht das Ergebnis. */ + await seite.waitForTimeout(1300); + return { + weg: seite.url().replace(BASIS, ""), + sichtbar: await seite.evaluate(() => getComputedStyle(document.body).visibility), + codefeld: await seite.evaluate(() => !!document.getElementById("code")), + }; +} + +/* ===================================================================== + 1 · OHNE ANMELDUNG + ===================================================================== */ +melde("\n=== Ohne Anmeldung ==="); +let kontext = await auf(); +let seite = await kontext.newPage(); +let r = await hin(seite, "/workspace/"); +ok(r.weg === "/workspace/", `die Anmeldung bleibt stehen (${r.weg})`); +ok(r.codefeld, "das Codefeld ist da"); +/* DIE WICHTIGSTE ZEILE DIESES ABSCHNITTS. Die Verdeckung waehrend der + Abfrage darf NIE haengenbleiben -- sonst koennte sich niemand mehr + anmelden, und zwar genau dann, wenn das Netz schlecht ist. */ +ok(r.sichtbar === "visible", `und die Seite ist sichtbar (${r.sichtbar})`); +ok(sitzungen() === 0, `noch keine Sitzung in der Datenbank (${sitzungen()})`); + +/* ===================================================================== + 2 · ANMELDEN + ===================================================================== */ +melde("\n=== Anmelden ==="); +await seite.click('.rolle[data-rolle="scout"]'); +await seite.fill("#code", CODE); +await seite.click("button[type=submit]"); +await seite.waitForURL("**/start.html", { timeout: 15000 }); +ok(sitzungen() === 1, `genau eine Sitzung entstanden (${sitzungen()})`); + +const keks = (await kontext.cookies()).find((c) => c.name === "dfw_sitzung"); +braucht(!!keks, "der Sitzungskeks wurde gar nicht gesetzt"); +const tage = Math.round((keks.expires * 1000 - Date.now()) / 86400000); +ok(tage > 170, `der Keks ueberlebt das Schliessen des Browsers (${tage} Tage)`); + +/* ===================================================================== + 3 · DIE ADRESSE, DIE MAN TIPPT + ===================================================================== + Genau hier lag der Fehler: `/workspace/` zeigte die Anmeldung, + obwohl die Sitzung gueltig war. + ===================================================================== */ +melde("\n=== Die Adresse, die man tippt ==="); +for (const runde of [1, 2, 3]) { + r = await hin(seite, "/workspace/"); + ok(r.weg === "/workspace/start.html", + `Aufruf ${runde}: fuehrt direkt zur Startseite (${r.weg})`); + ok(!r.codefeld, `Aufruf ${runde}: kein Codefeld`); +} +ok(sitzungen() === 1, + `nach drei Aufrufen immer noch EINE Sitzung (${sitzungen()})` + + " — niemand wurde stillschweigend neu angemeldet"); + +/* ===================================================================== + 4 · BROWSER ZU UND WIEDER AUF + ===================================================================== */ +melde("\n=== Browser schliessen und neu oeffnen ==="); +await kontext.close(); +kontext = await auf(); +seite = await kontext.newPage(); +r = await hin(seite, "/workspace/"); +ok(r.weg === "/workspace/start.html", `auch danach direkt zur Startseite (${r.weg})`); +ok(sitzungen() === 1, `und immer noch eine einzige Sitzung (${sitzungen()})`); + +/* ===================================================================== + 5 · ABMELDEN -- und dann muss Schluss sein + ===================================================================== + Die Gegenprobe zu allem davor. Ohne sie hiesse „man bleibt + angemeldet" moeglicherweise „man kommt gar nicht mehr heraus". + ===================================================================== */ +melde("\n=== Abmelden ==="); +await seite.goto(`${BASIS}/workspace/start.html`, { waitUntil: "networkidle" }); +await seite.waitForTimeout(900); +const abmelden = await seite.$("#abmelden"); +braucht(!!abmelden, "der Abmelden-Knopf ist nicht da"); +await abmelden.click(); +/* ABMELDEN FRAGT ERST NACH (kopf.js, seit 19.09.2026) -- und das aus + gutem Grund: Zum Wiederanmelden braucht man den persoenlichen + Zugangscode, und den bekommt jeder nur einmal. + + Beim ersten Lauf hat diese Pruefung nur geklickt und dann gemeldet + „die Sitzung ist nicht weg". Das war kein Befund am Code, sondern + eine Pruefung, die auf halbem Weg stehen blieb. */ +await seite.waitForTimeout(600); +/* GENAU der Bestaetigungsknopf. Beim ersten Anlauf stand hier eine + Auswahl mit Komma, und die traf den erstbesten `.knopf` im Dialog + -- das war „Bild waehlen", unsichtbar, und der Klick lief 30 + Sekunden in eine Zeitueberschreitung. */ +const bestaetigen = await seite.$('dialog[open] [data-teil="ja"]'); +braucht(!!bestaetigen, "die Rueckfrage beim Abmelden kam nicht"); +await bestaetigen.click(); +await seite.waitForTimeout(1800); +ok(sitzungen() === 0, `die Sitzung ist wirklich weg (${sitzungen()})`); + +r = await hin(seite, "/workspace/"); +ok(r.weg === "/workspace/", `danach steht die Anmeldung da (${r.weg})`); +ok(r.codefeld, "mit Codefeld"); +ok(r.sichtbar === "visible", `und sichtbar (${r.sichtbar})`); + +/* Und sie bleibt stehen -- kein Hin und Her zwischen zwei Seiten. */ +r = await hin(seite, "/workspace/"); +ok(r.weg === "/workspace/" && r.codefeld, + "auch beim zweiten Aufruf — kein Hin und Her"); + +/* ===================================================================== + 6 · GEGENPROBE: MISST DIESE PRUEFUNG UEBERHAUPT ETWAS? + ===================================================================== + Wenn „fuehrt zur Startseite" auch ohne gueltige Sitzung herauskaeme, + waere der ganze Abschnitt 3 wertlos. Also noch einmal anmelden, den + Keks von Hand wegnehmen und nachsehen, ob die Messung es merkt. + ===================================================================== */ +melde("\n=== Gegenprobe ==="); +await seite.goto(`${BASIS}/workspace/`, { waitUntil: "networkidle" }); +await seite.waitForTimeout(900); +await seite.click('.rolle[data-rolle="scout"]'); +await seite.fill("#code", CODE); +await seite.click("button[type=submit]"); +await seite.waitForURL("**/start.html", { timeout: 15000 }); +r = await hin(seite, "/workspace/"); +ok(r.weg === "/workspace/start.html", "angemeldet: fuehrt zur Startseite"); + +await kontext.clearCookies(); +r = await hin(seite, "/workspace/"); +ok(r.weg === "/workspace/" && r.codefeld, + "ohne Keks: die Anmeldung — die Messung kann also auch Nein sagen"); + +await kontext.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +melde(`\n${geprueft} Pruefungen, ${fehler} Fehler`); +process.exit(fehler ? 1 : 0); diff --git a/server/workspace.js b/server/workspace.js index 23b8ad86..2ff3aef2 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -6984,6 +6984,64 @@ export function gehoertAufDieseAdresse(person, pfad) { return ziele.has(pfad); } +/* ===================================================================== + WER SCHON ANGEMELDET IST, SIEHT DIE ANMELDUNG NICHT (06.10.2026) + ===================================================================== + + Filipe: „ich will dass wenn sich jemand bei der workspace seite + anmeldet, angemeldet bleibt bis er sich selbst abmeldet." + + GEMESSEN, BEVOR ETWAS GEBAUT WURDE -- und das hat die Richtung + gedreht. Die Sitzung war nie zu kurz: + + * Der Keks gilt 180 Tage und verlaengert sich beim Benutzen. + * In der echten Datenbank lief KEINE der 28 Sitzungen vor + Maerz 2027 ab. + * Am echten Browser: Er uebersteht Neuladen, den Service Worker + und das Schliessen des Browsers. + + Trotzdem meldeten sich dieselben Geraete staendig neu an -- + DogFathers Android am 05.10. um 22:54 UND um 22:55, gleiche IP, + gleiche Browserkennung. Der Grund: `/workspace/` lieferte die + Anmeldung aus, ohne zu fragen, ob schon jemand angemeldet ist. + Das ist die Adresse, die man tippt, als Lesezeichen hat und + weitergibt. Wer sie oeffnete, sah das Codefeld und tippte den Code + noch einmal -- obwohl seine Sitzung gueltig war. Jede Eingabe legt + eine neue Sitzung an; deshalb standen sieben davon bei einer + Person. + + Haette ich die Sitzungsdauer erhoeht -- die naheliegende Antwort --, + waere der Fehler geblieben und die Sicherheit schlechter geworden. + + (Die installierte App startet auf `start.html` und war nie + betroffen. Es traf nur den Weg ueber die Adresszeile, also den + haeufigsten.) + + WARUM AUF DEM SERVER UND NICHT IM BROWSER: Der erste Entwurf stand + in gate.js und fragte `/api/ich`. Das brauchte eine Verdeckung + gegen das Aufblitzen, eine Notfrist gegen eine haengende Abfrage + und eine Schleifensperre mit Zeitstempel in zwei Dateien -- und + hinterliess bei jedem Aufruf der Anmeldung ein 401 in der + Browserkonsole (pruef-neue-seiten wurde davon zehnmal rot). Hier + ist es eine Zeile: Die Sitzung wird ohnehin bei jeder Anfrage + gelesen. + + KEINE SCHLEIFENGEFAHR: Schickt die Startseite jemanden zurueck + (nur bei einer Sitzung, die zwischendurch ungueltig wurde), findet + diese Stelle keine Sitzung mehr und liefert die Anmeldung aus. + + BEIDE HAEUSER: Auf der Crew-Adresse liefert `/workspace/` die + Datei crew-index.html; der Weg dorthin ist derselbe, und das Ziel + `start.html` gilt dort ebenso. Der Keks ist host-gebunden -- wer + nur im anderen Haus angemeldet ist, bekommt hier weiterhin die + Anmeldung, genau wie es die Haustrennung vom 24.09.2026 verlangt. */ +workspaceRouter.get( + ["/workspace/", "/workspace/index.html", "/workspace/crew-index.html"], + (req, res, next) => { + if (!sitzungLesen(req)) return next(); + return res.redirect(302, "/workspace/start.html"); + }); + workspaceRouter.use((req, res, next) => { const pfad = schrankenPfad(req.path); if (!pfad.startsWith("/workspace/") || !pfad.endsWith(".html")) return next(); diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 4ef33eb5..48e7c690 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +