Registrierung: E-Mail-Bestätigung per Einmalcode vor dem Zugangscode

Nutzer-Wunsch 21.08.2026: "bei der ersten registrierung sollen die eine
email bekommen mit einem einmaligen code damit wir auch wissen dass
email stimmt und danach wenn sie den code eingegeben haben sollen die
erst ihren zugangscode auswählen/eintippen können."

Damit wird eine echte Lücke geschlossen: registerSupporter() hat bisher
`email_verified = 1` gesetzt, OHNE dass irgendetwas geprüft wurde -- man
konnte sich mit einer fremden oder erfundenen Adresse registrieren und
kam sofort rein.

Neuer Ablauf in drei Schritten:
1. Name/TikTok/E-Mail -> Konto wird als UNBESTÄTIGT angelegt
   (email_verified = 0, noch kein Zugangscode). Es kommt bewusst KEIN
   Session-Token zurück -- eingeloggt ist man hier noch nicht.
2. Einmalcode aus der E-Mail eingeben -> verify-email bestätigt und
   loggt ein. Die Willkommens-Mail wandert hierher, sie ging vorher an
   eine noch ungeprüfte Adresse.
3. Erst jetzt den eigenen Zugangscode festlegen.

Serverseitig abgesichert: setSupporterAccessCode() lehnt ab, solange die
E-Mail nicht bestätigt ist -- der Schritt ist damit nicht nur im
Formular versteckt, sondern auch per Direktaufruf nicht überspringbar.

Wiederverwendet wird die bereits vorhandene Mechanik (issueAuthCode/
verifyAuthCode mit Zweck "verify_email", sendVerifyEmailCode, die Panels
#panel-code und #panel-neuer-code) -- der "Code vergessen?"-Weg nutzt
dieselbe Code-Eingabe und bleibt unverändert; eine neue Variable
codeZweck unterscheidet, welcher Endpunkt aufgerufen wird.

Mit Playwright end-to-end geprüft (14 Tests): Reihenfolge der Aufrufe,
kein Token vor der Bestätigung, falscher Code kommt nicht weiter,
Zugangscode-Feld erst im letzten Schritt, "Code vergessen?" unberührt.

Cache-Busting-Version auf 20260821o erhöht.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-21 17:48:08 +02:00
co-authored by Claude Opus 5
parent 5d0ea11570
commit 5a5912636d
37 changed files with 331 additions and 286 deletions
+53 -17
View File
@@ -72,54 +72,69 @@ function pruefeCode(code) {
Kontrolle geht eine Willkommens-E-Mail raus; schlägt deren Versand fehl,
ist das KEIN Grund die Registrierung scheitern zu lassen (das Konto ist
ja schon nutzbar) — sie wird nur im Protokoll vermerkt. */
/* Schritt 1 von 3 der Registrierung (Nutzer-Wunsch 21.08.2026: "bei der
ersten registrierung sollen die eine email bekommen mit einem einmaligen
code damit wir auch wissen dass email stimmt und danach wenn sie den code
eingegeben haben sollen die erst ihren zugangscode auswählen/eintippen
können").
Vorher wurde hier direkt `email_verified = 1` gesetzt UND der Zugangscode
im selben Schritt entgegengenommen -- die E-Mail-Adresse wurde also nie
geprüft. Man konnte sich mit einer fremden oder erfundenen Adresse
registrieren und kam sofort rein.
Jetzt legt dieser Schritt nur das UNBESTÄTIGTE Konto an (email_verified = 0,
noch KEIN Zugangscode) und verschickt einen Einmalcode. Weiter geht es über
verifySupporterEmail() (Schritt 2) und erst danach setSupporterAccessCode()
(Schritt 3). Es wird hier bewusst KEIN Session-Token zurückgegeben -- ohne
bestätigte E-Mail ist man nicht eingeloggt. */
export async function registerSupporter(req, res) {
const body = req.body || {};
const displayName = String(body.displayName || "").trim().slice(0, 60);
const tiktokUsername = normalizeTikTokUsername(body.tiktokUsername);
const email = normalizeEmail(body.email);
const accessCode = String(body.accessCode || "");
if (!displayName) return bad(res, "Bitte gib einen Namen oder Anzeigenamen an.");
if (!email || !email.includes("@")) return bad(res, "Bitte gib eine gültige E-Mail-Adresse an.");
const codeFehler = pruefeCode(accessCode);
if (codeFehler) return bad(res, codeFehler);
const existing = db.prepare(`SELECT id, access_code_hash FROM supporter_users WHERE email = ?`).get(email);
if (existing && existing.access_code_hash) {
return bad(res, "Für diese E-Mail-Adresse gibt es schon ein Konto. Bitte logg dich einfach ein.", 409);
}
const codeHash = await hashAccessCode(accessCode);
let supporterId;
if (existing) {
// Konto war angelegt, hatte aber noch keinen eigenen Code (z.B. ein
// abgebrochener Versuch aus der Zeit davor) -> jetzt vervollständigen.
// Konto existiert, ist aber noch unfertig (abgebrochener Versuch, oder
// Altkonto ohne eigenen Code) -> Angaben auffrischen, Bestätigung neu
// anstoßen. Kein Fehler nach außen: ein zweiter Anlauf soll einfach
// funktionieren, statt in einer Sackgasse zu enden.
supporterId = existing.id;
db.prepare(
`UPDATE supporter_users
SET display_name = ?, tiktok_username = ?, email_verified = 1,
access_code_hash = ?, access_code_set_at = ?
SET display_name = ?, tiktok_username = ?, email_verified = 0
WHERE id = ?`
).run(displayName, tiktokUsername, codeHash, nowIso(), supporterId);
).run(displayName, tiktokUsername, supporterId);
} else {
supporterId = generateId();
db.prepare(
`INSERT INTO supporter_users
(id, display_name, tiktok_username, email, email_verified, created_at, access_code_hash, access_code_set_at, last_login_at)
VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?)`
).run(supporterId, displayName, tiktokUsername, email, nowIso(), codeHash, nowIso(), nowIso());
(id, display_name, tiktok_username, email, email_verified, created_at)
VALUES (?, ?, ?, ?, 0, ?)`
).run(supporterId, displayName, tiktokUsername, email, nowIso());
logAction("system", "supporter.registered", supporterId, { email });
}
// Willkommens-E-Mail: nice-to-have, kein Blocker (siehe Kommentar oben).
// Anders als die Willkommens-E-Mail ist DIESE Mail ein echter Blocker:
// ohne den Code kommt niemand weiter. Schlägt der Versand fehl, muss das
// sichtbar scheitern statt so zu tun, als sei alles in Ordnung.
const code = await issueAuthCode(supporterId, "verify_email");
try {
await sendWelcomeMail({ to: email, name: displayName });
await sendVerifyEmailCode({ to: email, name: displayName, code });
} catch (err) {
logAction("system", "supporter.welcome_mail_failed", supporterId, { fehler: String(err?.message || err).slice(0, 200) });
return emailErrorResponse(res, err);
}
const token = await createSupporterSession(supporterId);
return json(res, { ok: true, token });
return json(res, { ok: true });
}
/* Einloggen mit E-Mail + selbst gewähltem Code — der neue Normalweg.
@@ -177,6 +192,17 @@ export async function setSupporterAccessCode(req, res, supporter) {
const fehler = pruefeCode(neuerCode);
if (fehler) return bad(res, fehler);
// Zusätzliche Absicherung (21.08.2026): Ein Zugangscode darf NUR gesetzt
// werden, wenn die E-Mail-Adresse bestätigt ist. Aktuell kann ohne
// Bestätigung ohnehin keine Session entstehen (registerSupporter gibt kein
// Token mehr zurück) -- diese Prüfung hängt aber nicht davon ab, dass das
// so bleibt. Ohne sie wäre der Bestätigungsschritt nur im Formular
// versteckt und ließe sich mit einem direkten Aufruf überspringen.
const stand = db.prepare(`SELECT email_verified FROM supporter_users WHERE id = ?`).get(supporter.id);
if (!stand || !stand.email_verified) {
return bad(res, "Bitte bestätige zuerst deine E-Mail-Adresse.", 403);
}
const codeHash = await hashAccessCode(neuerCode);
db.prepare(`UPDATE supporter_users SET access_code_hash = ?, access_code_set_at = ? WHERE id = ?`).run(codeHash, nowIso(), supporter.id);
clearAttempts(`supporter:${supporter.email}`);
@@ -199,6 +225,16 @@ export async function verifySupporterEmail(req, res) {
db.prepare(`UPDATE supporter_users SET email_verified = 1, last_login_at = ? WHERE id = ?`).run(nowIso(), supporter.id);
logAction("system", "supporter.email_verified", supporter.id, null);
// Willkommens-E-Mail erst hier (21.08.2026) -- vorher ging sie schon beim
// Registrieren raus, also an eine noch ungeprüfte Adresse. Jetzt steht
// fest, dass die Adresse wirklich der Person gehört. Weiterhin
// nice-to-have: schlägt sie fehl, blockiert das die Anmeldung nicht.
try {
await sendWelcomeMail({ to: email, name: supporter.display_name });
} catch (err) {
logAction("system", "supporter.welcome_mail_failed", supporter.id, { fehler: String(err?.message || err).slice(0, 200) });
}
const token = await createSupporterSession(supporter.id);
return json(res, { ok: true, token });
}