Skip to content

Passwort-Reset-Ablauf ​

Einen sicheren token-basierten Passwort-Reset implementieren: Anforderung → Verifizierung → Abschluss.

Überblick ​

Ein Passwort-Reset-Ablauf hat drei Schritte:

  1. Benutzer fordert einen Reset an — ein zeitbegrenztes Token wird generiert und gesendet (z.B. per E-Mail).
  2. Benutzer verifiziert, dass das Token noch gültig ist, bevor das Reset-Formular angezeigt wird.
  3. Benutzer reicht ein neues Passwort ein — Token wird verbraucht und Passwort aktualisiert.

Datenbankschema ​

sql
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 speichert den SHA-256-Hash des Roh-Tokens. Das Roh-Token wird niemals in der Datenbank gespeichert.

Token-Generierung und -Speicherung ​

Das Roh-Token mit random_bytes generieren, dann nur den SHA-256-Hash speichern:

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

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

// $rawToken an den Benutzer zurückgeben (per E-Mail oder API-Antwort)

Bei der Verifizierung das eingehende Token auf dieselbe Weise hashen:

php
$tokenHash = hash('sha256', $rawToken);
$reset     = $this->repo->findByTokenHashOrNull($tokenHash);

Einen Hash zu speichern bedeutet, dass ein DB-Einbruch keine verwendbaren Reset-Tokens exponiert — ein Angreifer müsste SHA-256 auf einem 256-Bit-Zufallswert umkehren, was rechnerisch nicht machbar ist.

Benutzer-Enumerationsprävention ​

POST /password-reset muss immer 202 zurückgeben, auch für unbekannte E-Mail-Adressen:

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

// Immer 202 — nicht verraten, ob die E-Mail registriert ist
if ($user === null) {
    return $this->json->create(['status' => 'pending'], 202);
}

// ... Token für echten Benutzer generieren
return $this->json->create(['status' => 'pending', 'token' => $rawToken], 202);

404 für unbekannte E-Mails zurückzugeben würde einem Angreifer erlauben, registrierte Konten durch Sondieren von E-Mail-Adressen zu enumerieren.

Einmalige Verwendung ​

used_at setzen, wenn der Reset abgeschlossen wird. Jedes Token ablehnen, das used_at IS NOT NULL hat:

php
if ($reset->isUsed()) {
    return $this->problems->create($request, 'conflict', 'Reset token has already been used.', 409, '');
}

$this->repo->markUsed($tokenHash, $now);
php
public function isUsed(): bool
{
    return $this->usedAt !== null;
}

Ablauf ​

Ablauf sowohl bei GET (Status-Check) als auch POST (Abschluss) erzwingen. Ablauf immer vor isUsed() prüfen:

php
if ($reset->isExpired($now)) {
    return 410; // Gone — unterscheidet sich von "nicht gefunden" (404) und "verwendet" (409)
}
if ($reset->isUsed()) {
    return 409;
}

410 (Gone) unterscheidet "abgelaufen" von "verwendet" (409) und gibt dem Benutzer handlungsrelevante Informationen.

Altes Token invalidieren ​

Wenn ein Benutzer einen neuen Reset anfordert, alle vorherigen ungenutzten Tokens für diesen Benutzer invalidieren:

php
$this->executor->execute(
    "UPDATE password_resets SET used_at = ? WHERE user_id = ? AND used_at IS NULL",
    [$now, $userId],
);

Ohne dies hätte ein Benutzer, der eine Reset-E-Mail verloren hat und eine neue anfordert, zwei gültige Tokens gleichzeitig im Umlauf — beide könnten verwendet werden, um das Passwort zurückzusetzen.

Antwort-Bereinigung ​

GET /password-reset/{token} darf user_id oder token_hash nicht in der Antwort exponieren:

php
public function toArray(): array
{
    return [
        'id'         => $this->id,
        'expires_at' => $this->expiresAt,
        'created_at' => $this->createdAt,
    ];
}

user_id zu exponieren würde das Reset-Token mit einer Benutzer-Konto-ID verknüpfen, was unnötig ist, da das Token selbst die Autorisierungsanmeldedaten ist.

Sicherheitseigenschaften ​

EigenschaftImplementierung
Token-Entropiebin2hex(random_bytes(32)) — 256 Bits
Token-SpeicherungNur SHA-256-Hash — Roh-Token niemals in DB
Benutzer-EnumerationImmer 202 von POST /password-reset
Ablauf1 Stunde; bei GET und POST geprüft
Einmalige Verwendungused_at bei Abschluss gesetzt; 409 bei Wiederverwendung
Altes Token invalidierenVorherige ungenutzte Tokens bei neuer Anfrage auf verwendet gesetzt
Antwort-Leckuser_id und token_hash aus allen Antworten ausgeschlossen
Passwort-HashingArgon2id

Routen-Zusammenfassung ​

MethodePfadBeschreibung
POST/password-resetReset anfordern (immer 202)
GET/password-reset/{token}Token-Gültigkeit prüfen
POST/password-reset/{token}Reset mit neuem Passwort abschließen

Veröffentlicht unter der MIT-Lizenz.