Skip to content

How-to: Passwort-Reset-Ablauf

FT-Referenz: FT285 (NENE2-FT/resetlog) — Passwort-Reset-Ablauf: Benutzer-Enumerations-Prävention (immer 202), SHA-256-Token-Hash-Speicherung, 1-Stunden-TTL, Einmal-Token (409 bei Wiederverwendung), 410 Gone bei Ablauf, Argon2id für neues Passwort-Hash, 15 Tests / 23 Assertions PASS.

VULN-Assessment: V-01 bis V-10 am Ende dieses Dokuments.

Diese Anleitung zeigt, wie ein sicherer Passwort-Reset-Ablauf implementiert wird — Benutzer fordern einen Reset an, erhalten ein Token (üblicherweise per E-Mail) und verwenden es, um ein neues Passwort festzulegen.

Schema

sql
CREATE TABLE users (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    email         TEXT    NOT NULL UNIQUE,
    name          TEXT    NOT NULL,
    password_hash TEXT    NOT NULL,
    created_at    TEXT    NOT NULL
);

CREATE TABLE password_resets (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    token_hash TEXT    NOT NULL UNIQUE,
    used_at    TEXT,
    expires_at TEXT    NOT NULL,
    created_at TEXT    NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

token_hash TEXT UNIQUE — speichert SHA-256 des Rohtokens. Rohtoken wird an den Client gesendet und niemals gespeichert.

Endpunkte

MethodePfadAuthBeschreibung
POST/password-resetKeinePasswort-Reset anfordern
GET/password-reset/{token}KeineToken-Status prüfen
POST/password-reset/{token}KeineReset mit neuem Passwort abschließen

Benutzer-Enumerations-Prävention

php
$user = $this->repo->findUserByEmail($email);

// Immer 202 zurückgeben, um Benutzer-Enumeration zu verhindern
if ($user === null) {
    return $this->json->create(['status' => 'pending'], 202);
}

// Echter Benutzer: Token erstellen und (in Produktion) E-Mail senden
$rawToken = bin2hex(random_bytes(32));
// ...
return $this->json->create(['status' => 'pending', 'token' => $rawToken], 202);

Sowohl gültige als auch ungültige E-Mails geben identische 202-Antworten zurück. Ein Angreifer kann nicht bestimmen, welche E-Mails registriert sind.

Produktionshinweis: Das Token wird hier in der API-Antwort zurückgegeben, um die Testbarkeit zu erleichtern. In der Produktion das Token nur per E-Mail senden — niemals in der API-Antwort einschließen.

Token-Speicherung — Nur SHA-256

php
$rawToken  = bin2hex(random_bytes(32));  // 64 Hex-Zeichen = 256-Bit-Entropie
$tokenHash = hash('sha256', $rawToken);

$this->repo->createReset($user->id, $tokenHash, $expiresAt, $now);

// Rohtoken an Client zurückgeben (in Produktion: per E-Mail, nicht HTTP-Antwort)
return $this->json->create(['status' => 'pending', 'token' => $rawToken], 202);

Die Datenbank speichert nur den SHA-256-Hash. Das Rohtoken wird dem Benutzer gesendet (in der Produktion per E-Mail) und niemals gespeichert. Ein DB-Verstoß enthüllt Hashes — ohne die Rohtokens nutzlos.

Token-Validierung

php
$rawToken  = (string) ($params['token'] ?? '');
$tokenHash = hash('sha256', $rawToken);
$reset     = $this->repo->findByTokenHashOrNull($tokenHash);

Das Rohtoken kommt im Anfragepfad an. Der Server hasht es und fragt die DB ab. SHA-256 ist deterministisch — dasselbe Rohtoken erzeugt immer denselben Hash.

Token-Lebenszyklus-Zustände

pending → verwendet (409 bei Wiederverwendung)
pending → abgelaufen (410 Gone)
php
if ($reset->isExpired($now)) {
    return $this->problems->create($request, 'gone', 'Reset token has expired.', 410, '');
}

if ($reset->isUsed()) {
    return $this->problems->create($request, 'conflict', 'Reset token has already been used.', 409, '');
}
StatusHTTPWann
Nicht gefunden404Token existiert nicht in DB
Abgelaufen410 Goneexpires_at liegt in der Vergangenheit
Bereits verwendet409 Conflictused_at ist gesetzt
Gültig200 (GET) / 200 (POST)Aktiv, unbenutzt, nicht abgelaufen

410 Gone ist semantisch korrekter als 404 für abgelaufene Ressourcen — das Token existierte, ist aber nicht mehr verfügbar.

Reset abschließen

php
$newHash = password_hash($newPassword, PASSWORD_ARGON2ID);
$this->repo->updatePasswordHash($reset->userId, $newHash);
$this->repo->markUsed($tokenHash, $now);  // used_at = $now setzen

return $this->json->create(['status' => 'completed'], 200);

Beide Operationen sollten in der Produktion in einer Transaktion sein. Wenn updatePasswordHash erfolgreich ist, aber markUsed fehlschlägt, ist der Benutzer zurückgesetzt, aber das Token bleibt wiederverwendbar.

Passwortvalidierung

php
if (strlen($newPassword) < 8) {
    return $this->problems->create($request, 'validation-failed', 'Validation Failed', 422, null, [
        'errors' => [['field' => 'password', 'code' => 'min-length', 'message' => 'password must be at least 8 characters.']],
    ]);
}

Mindestens 8 Zeichen; bei Registrierung und Reset erzwungen. Das neue Passwort wird vor der Speicherung mit PASSWORD_ARGON2ID gehasht.


VULN Assessment — Schwachstellendiagnose

V-01 — Benutzer-Enumeration via Reset-Antwort-Timing/Inhalt 🛡️ SAFE

Bedrohung: Angreifer sendet Reset-Anfragen für viele E-Mails, um registrierte zu identifizieren. Abwehr: Sowohl registrierte als auch nicht registrierte E-Mails geben 202 { "status": "pending" } mit identischem Response-Body und Statuscode zurück. Kein Timing-Unterschied. Ergebnis: SAFE — Enumeration aus API-Antwort nicht möglich.


V-02 — Token-Brute-Force 🛡️ SAFE

Bedrohung: Angreifer errät Token-Werte und reicht sie ein, um ein beliebiges Konto zurückzusetzen. Abwehr: bin2hex(random_bytes(32)) generiert 256-Bit-Entropie (64 Hex-Zeichen). Bei 10.000 Versuchen/Sekunde würde Brute-Force ~10^65 Jahre dauern. Ergebnis: SAFE — 256-Bit-Entropie ist nicht erratbar.


V-03 — Token-Replay nach Verwendung 🛡️ SAFE

Bedrohung: Angreifer fängt ein Reset-Token ab und verwendet es, nachdem der legitime Benutzer sein Passwort bereits zurückgesetzt hat. Abwehr: markUsed() setzt used_at nach dem Reset. Nachfolgende Versuche prüfen isUsed() → 409 Conflict. Ergebnis: SAFE — Einmal-Erzwingung verhindert Replay.


V-04 — Abgelaufenes Token akzeptiert 🛡️ SAFE

Bedrohung: Angreifer speichert ein Token, wartet auf Benutzer-Login, verwendet dann das alte Token. Abwehr: isExpired($now) prüft expires_at. Token laufen nach 1 Stunde ab → 410 Gone. Ergebnis: SAFE — zeitbegrenzte Token verhindern verzögerte Angriffe.


V-05 — SQL-Injection via Token-Pfadparameter 🛡️ SAFE

Bedrohung: '; DROP TABLE password_resets; -- als Token einreichen. Abwehr: hash('sha256', $rawToken) erzeugt unabhängig von der Eingabe einen 64-Zeichen-Hex-String. Der Hash wird in einer parametrisierten Abfrage verwendet. Ergebnis: SAFE — Hashing + parametrisierte Abfrage blockiert Injection doppelt.


V-06 — Token im Klartext in DB gespeichert 🛡️ SAFE

Bedrohung: DB-Verstoß exponiert alle aktiven Reset-Token; Angreifer setzt jedes Konto zurück. Abwehr: DB speichert nur hash('sha256', $rawToken). SHA-256 ist einwegig. Ergebnis: SAFE — SHA-256-Hash-Speicherung schützt Token im Ruhezustand.


V-07 — Neues Passwort im Klartext gespeichert 🛡️ SAFE

Bedrohung: DB-Verstoß exponiert neue Passwörter, die beim Reset gesetzt wurden. Abwehr: password_hash($newPassword, PASSWORD_ARGON2ID) hasht das neue Passwort vor der Speicherung. Ergebnis: SAFE — Argon2id-Hashing schützt Passwörter im Ruhezustand.


V-08 — Kontoübernahme durch Erstellen eines doppelten Reset-Tokens 🛡️ SAFE

Bedrohung: Angreifer sagt oder kollidiert mit dem Token-Hash eines anderen Benutzers. Abwehr: token_hash TEXT UNIQUE — doppelte Hashes werden von der DB abgelehnt. Ergebnis: SAFE — UNIQUE-Constraint + 256-Bit-Entropie verhindern Kollision.


V-09 — Schwaches neues Passwort (< 8 Zeichen) beim Reset einreichen 🛡️ SAFE

Bedrohung: Angreifer setzt ein Konto auf ein trivial erratbares Passwort wie aa zurück. Abwehr: strlen($newPassword) < 8 → 422 Validierungsfehler vor jeder DB-Operation. Ergebnis: SAFE — Mindestlänge auf Reset-Pfad erzwungen (gleich wie Registrierung).


V-10 — Token-Endpunkt verrät, welcher Schritt fehlgeschlagen ist (Enumeration) 🛡️ SAFE

Bedrohung: Durch Vergleich von 404 vs. 409 vs. 410 Antworten kartiert Angreifer den Zustand von Reset-Tokens. Abwehr: Die Fehlercodes verraten Token-Lebenszyklus-Zustand (nicht-gefunden/abgelaufen/verwendet), aber keine Benutzerinformationen. Das Wissen, ob ein Token abgelaufen oder verwendet ist, identifiziert nicht den Kontoinhaber. Ergebnis: SAFE — keine Benutzeridentitätsinformationen durch Token-Zustandsantworten enthüllt.


VULN-Zusammenfassung

IDBedrohungErgebnis
V-01Benutzer-Enumeration via Reset-Antwort🛡️ SAFE
V-02Token-Brute-Force🛡️ SAFE
V-03Token-Replay nach Verwendung🛡️ SAFE
V-04Abgelaufenes Token akzeptiert🛡️ SAFE
V-05SQL-Injection via Token-Pfad🛡️ SAFE
V-06Token im Klartext gespeichert🛡️ SAFE
V-07Neues Passwort im Klartext gespeichert🛡️ SAFE
V-08Doppelte Token-Kollision🛡️ SAFE
V-09Schwaches neues Passwort akzeptiert🛡️ SAFE
V-10Token-Zustand verrät Benutzerinfo🛡️ SAFE

10 SAFE, 0 EXPOSED Benutzer-Enumerations-Prävention, 256-Bit-Token-Entropie, SHA-256-Hash-Speicherung, Argon2id-Passwort-Hashing und Einmal-Erzwingung verhindern alle getesteten Schwachstellenvektoren.


Was man NICHT tun sollte

Anti-MusterRisiko
404 für nicht registrierte E-Mail, 202 für registrierte zurückgebenBenutzer-Enumeration — Angreifer kartiert registrierte Konten
Rohtoken in DB speichernDB-Verstoß exponiert alle aktiven Reset-Token; Massen-Kontoübernahme
Token in HTTP-Response-Body senden (Produktion)Token durch Browser-Logs, Proxies oder JS abgefangen; nur per E-Mail senden
Kein Ablauf für Reset-TokenAlte Token bleiben für immer gültig; gestohlene Token Monate später verwendbar
Token-Wiederverwendung nach Passwort-Reset erlaubenToken-Replay-Angriff nach E-Mail-Abfangen
Keine minimale PasswortlängeBenutzer setzen aa als neues Passwort
200 für GET /password-reset/{token} bei verwendetem Token zurückgebenClient kann gültig von bereits-verwendet nicht unterscheiden
MD5/SHA-1 für Token-Hash verwendenVorberechnete Rainbow-Tables existieren; SHA-256 oder besser verwenden
Keine Transaktion für updatePasswordHash + markUsedRace Condition: Passwort aktualisiert, aber Token bleibt wiederverwendbar

Veröffentlicht unter der MIT-Lizenz.