Arbeit der parallelen Session in git gesichert (war nur auf dem Server)
Diese Änderungen liefen bereits live auf dem Server, lagen dort aber ausschließlich als nicht eingecheckte Arbeitskopie — bei jedem Deploy (stash/pull/pop) und bei jedem Serverproblem wären sie verloren gewesen. Deshalb hier unverändert in git übernommen, bevor darauf aufgebaut wird. Enthalten (nicht von mir gebaut, nur gesichert): - Supporter: eigener fester Zugangscode statt Einmalcode-Login (Migration 0009, lib/crypto.js scrypt-Hash, routes/supporter.js, abonnieren.html, supporter.html, i18n-abonnieren/-supporter) - Stimmen: Profilbilder (Migration 0010, routes/testimonials.js) - Event-Bild-Upload: voller Pfad statt relativem (routes/events.js) - Neue/überarbeitete Hintergrundbilder für viele Seiten - Teilen-Funktion (streamplan.js, i18n-index/-streamplan, main.css) Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -60,3 +60,57 @@ export async function decryptCode(encoded) {
|
||||
export function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Persönlicher Zugangscode der Supporter (21.08.2026)
|
||||
|
||||
BEWUSST NICHT sha256Hex() wie bei den Einmalcodes oben: ein Einmalcode
|
||||
lebt 15 Minuten und ist zufällig, da genügt ein schneller Hash. Ein
|
||||
selbst gewählter, dauerhafter Code ist dagegen oft kurz und erratbar —
|
||||
dagegen hilft nur ein absichtlich LANGSAMES Verfahren, das massenhaftes
|
||||
Durchprobieren unbezahlbar macht. Dafür ist scrypt gemacht (in Node
|
||||
eingebaut, keine zusätzliche Abhängigkeit nötig).
|
||||
|
||||
Jeder Code bekommt zusätzlich einen eigenen Zufalls-Salt: dadurch haben
|
||||
zwei Personen mit demselben Code trotzdem völlig verschiedene Hashes,
|
||||
und vorberechnete Tabellen sind wertlos.
|
||||
|
||||
Gespeichertes Format: "scrypt$<salt-hex>$<hash-hex>"
|
||||
===================================================================== */
|
||||
import { scrypt as _scrypt, randomBytes, timingSafeEqual as _timingSafeEqual } from "node:crypto";
|
||||
|
||||
const SCRYPT_KEYLEN = 64;
|
||||
// N=16384 ist der Node-Standard und ein guter Kompromiss: für eine einzelne
|
||||
// Anmeldung kaum spürbar, für Angreifer aber millionenfach teuer.
|
||||
const SCRYPT_OPTIONEN = { N: 16384, r: 8, p: 1 };
|
||||
|
||||
function scryptAsync(code, salt) {
|
||||
return new Promise((resolve, reject) => {
|
||||
_scrypt(code, salt, SCRYPT_KEYLEN, SCRYPT_OPTIONEN, (err, key) => (err ? reject(err) : resolve(key)));
|
||||
});
|
||||
}
|
||||
|
||||
/** Erzeugt den zu speichernden Wert für einen selbst gewählten Zugangscode. */
|
||||
export async function hashAccessCode(code) {
|
||||
const salt = randomBytes(16);
|
||||
const key = await scryptAsync(String(code), salt);
|
||||
return `scrypt$${salt.toString("hex")}$${key.toString("hex")}`;
|
||||
}
|
||||
|
||||
/** Prüft einen eingegebenen Code gegen den gespeicherten Wert.
|
||||
Der Vergleich läuft zeitkonstant (timingSafeEqual), damit sich aus der
|
||||
Antwortdauer nicht ableiten lässt, wie viele Zeichen gestimmt haben. */
|
||||
export async function verifyAccessCode(code, gespeichert) {
|
||||
if (!gespeichert || typeof gespeichert !== "string") return false;
|
||||
const teile = gespeichert.split("$");
|
||||
if (teile.length !== 3 || teile[0] !== "scrypt") return false;
|
||||
try {
|
||||
const salt = Buffer.from(teile[1], "hex");
|
||||
const erwartet = Buffer.from(teile[2], "hex");
|
||||
const tatsaechlich = await scryptAsync(String(code), salt);
|
||||
if (erwartet.length !== tatsaechlich.length) return false;
|
||||
return _timingSafeEqual(erwartet, tatsaechlich);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,27 @@ export async function sendLoginCode({ to, name, code }) {
|
||||
});
|
||||
}
|
||||
|
||||
/* Willkommens-E-Mail nach der Registrierung (21.08.2026). Enthält bewusst
|
||||
KEINEN Code — man hat sich ja gerade selbst einen gesetzt und ist schon
|
||||
eingeloggt. Sie dient als Beleg, dass die Adresse stimmt, und als
|
||||
Erinnerung, wo man wieder reinkommt. */
|
||||
export async function sendWelcomeMail({ to, name }) {
|
||||
return send({
|
||||
to,
|
||||
subject: "Willkommen in der DogiCrew 🩵",
|
||||
html: WRAP(
|
||||
`Willkommen, ${name}! 🩵`,
|
||||
`<p style="line-height:1.6;">Dein Konto ist fertig. Du kommst ab jetzt jederzeit mit dieser
|
||||
E-Mail-Adresse und deinem selbst gewählten Zugangscode rein.</p>
|
||||
<p style="line-height:1.6;">Solltest du deinen Code mal vergessen, gibt es auf der Anmeldeseite
|
||||
„Code vergessen?“ — darüber bekommst du hier per E-Mail einen Einmalcode und kannst dir
|
||||
einen neuen Zugangscode setzen.</p>
|
||||
<p style="color:#9aa4b2;font-size:13px;">Hast du dich nicht angemeldet? Dann ignoriere diese
|
||||
E-Mail einfach — ohne deinen Zugangscode kommt niemand in das Konto.</p>`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendMilestoneUnlocked({ to, name, premiumLabel }) {
|
||||
return send({
|
||||
to,
|
||||
|
||||
Reference in New Issue
Block a user