Skip to content

How-to: OTP-Authentifizierungssystem ​

FT-Referenz: FT290 (NENE2-FT/otplog) — OTP-Authentifizierung: 6-stelliger numerischer Code mit SHA-256-Hash-Speicherung, Brute-Force-Sperrung (3 Versuche → 10 Min), OTP-TTL (5 Min), Replay-Angriffs-Prävention via used_at, Session-Token mit SHA-256 + Widerruf, Benutzer-Enumerations-Prävention via Always-202-Anfrage-Endpunkt, ATK-01~12 PASS, 35 Tests / 44 Assertions PASS.

Diese Anleitung zeigt, wie ein passwortloses OTP-Authentifizierungssystem (Einmalpasswort) erstellt wird, bei dem Benutzer einen 6-stelligen Code erhalten und ihn gegen ein Session-Token eintauschen.

Schema ​

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

CREATE TABLE otp_codes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL,
    code_hash TEXT NOT NULL,
    expires_at TEXT NOT NULL,
    used_at TEXT,
    attempt_count INTEGER NOT NULL DEFAULT 0,
    locked_until TEXT,
    created_at TEXT NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE TABLE otp_sessions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL,
    session_token_hash TEXT NOT NULL UNIQUE,
    expires_at TEXT NOT NULL,
    revoked_at TEXT,
    created_at TEXT NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

Wichtige Designpunkte:

  • code_hash speichert SHA-256 des OTP, niemals den Rohcode.
  • attempt_count + locked_until implementieren Brute-Force-Sperrung pro OTP-Zeile.
  • used_at verhindert Replay-Angriffe (OTP kann nur einmal verwendet werden).
  • session_token_hash speichert SHA-256 des Session-Tokens; UNIQUE verhindert Kollisionen.
  • revoked_at ermöglicht expliziten Logout ohne Löschen der Zeile.

Endpunkte ​

MethodePfadAuthBeschreibung
POST/otp/requestkeineOTP anfordern (erstellt Benutzer bei Bedarf)
POST/otp/verifykeineOTP verifizieren, Session-Token erhalten
GET/otp/sessionBearer <token>Session-Informationen abrufen
DELETE/otp/sessionBearer <token>Logout (Session widerrufen)

OTP-Generierung — Rohcode niemals speichern ​

php
private const int MAX_ATTEMPTS = 3;
private const int OTP_TTL_MINUTES = 5;
private const int LOCK_MINUTES = 10;
private const int SESSION_TTL_HOURS = 24;

$rawCode = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$codeHash = hash('sha256', $rawCode);
$this->repository->createOtp($userId, $codeHash, $now);

str_pad stellt führende Nullen sicher (z.B. gibt random_int(0, 999999) 42 zurück → '000042'). Der Rohcode wird an die E-Mail des Benutzers gesendet; nur der Hash wird gespeichert. random_int() ist kryptografisch sicher.

Benutzer-Enumerations-Prävention — Immer 202 ​

php
// Immer 202 — verhindert Benutzer-Enumeration
// In Produktion: E-Mail senden. In diesem FT geben wir den Code zum Testen zurück.
return $this->responseFactory->create([
    'message' => 'OTP code sent',
    'code' => $rawCode,  // in Produktion entfernen
], 202);

Unabhängig davon, ob die E-Mail existiert oder nicht, ist die Antwort immer 202 Accepted. Ein Angreifer kann „Konto existiert" nicht von „Konto existiert nicht" unterscheiden.

Benutzer bei erster Anfrage automatisch erstellen ​

php
public function findOrCreateUser(string $email, string $now): int
{
    $user = $this->findUserByEmail($email);
    if ($user !== null) {
        return (int) $user['id'];
    }
    return $this->executor->insert(
        'INSERT INTO users (email, created_at) VALUES (?, ?)',
        [$email, $now]
    );
}

Benutzer werden implizit bei der ersten OTP-Anfrage erstellt — kein separater Registrierungsschritt erforderlich. Der UNIQUE(email)-Constraint verhindert Duplikate bei gleichzeitigen Inserts.

OTP-Verifizierung — Reihenfolge der Prüfungen ​

php
// 1. Sperrprüfung (zuerst — vor jedem Code-Vergleich)
if ($otp['locked_until'] !== null && $now < (string) $otp['locked_until']) {
    return $this->responseFactory->create(['error' => 'too many attempts, try again later'], 429);
}

// 2. Ablaufprüfung
if ($now > (string) $otp['expires_at']) {
    return $this->responseFactory->create(['error' => 'code expired'], 401);
}

// 3. Bereits-verwendet-Prüfung
if ($otp['used_at'] !== null) {
    return $this->responseFactory->create(['error' => 'code already used'], 401);
}

// 4. Code-Prüfung mit hash_equals (timing-sicher)
$codeHash = hash('sha256', $code);
if (!hash_equals((string) $otp['code_hash'], $codeHash)) {
    $this->repository->incrementAttempt((int) $otp['id'], $now);
    return $this->responseFactory->create(['error' => 'invalid code'], 401);
}

Die Reihenfolge der Prüfungen ist wichtig: Sperre → Ablauf → verwendet → Code. attempt_count nur bei einem falschen Code inkrementieren — nicht bei Sperre oder Ablauf.

Brute-Force-Sperrung ​

php
public function incrementAttempt(int $otpId, string $now): void
{
    $otp = $this->executor->fetchOne('SELECT * FROM otp_codes WHERE id = ?', [$otpId]);
    if ($otp === null) {
        return;
    }
    $newCount = (int) $otp['attempt_count'] + 1;
    $lockedUntil = null;
    if ($newCount >= self::MAX_ATTEMPTS) {
        $lockedUntil = date('c', strtotime($now) + self::LOCK_MINUTES * 60);
    }
    $this->executor->execute(
        'UPDATE otp_codes SET attempt_count = ?, locked_until = ? WHERE id = ?',
        [$newCount, $lockedUntil, $otpId]
    );
}

Nach MAX_ATTEMPTS (3) falschen Codes wird locked_until 10 Minuten in die Zukunft gesetzt. Die Sperrprüfung erfolgt vor jedem Code-Vergleich, daher setzen Versuche während der Sperre den Timer nicht zurück.

Nur neuestes OTP — Neue Anfrage ersetzt alte ​

php
public function findLatestOtpForUser(int $userId): ?array
{
    return $this->executor->fetchOne(
        'SELECT * FROM otp_codes WHERE user_id = ? ORDER BY id DESC LIMIT 1',
        [$userId]
    );
}

Mehrere OTP-Anfragen erstellen mehrere Zeilen, aber nur die neueste wird zur Verifizierung verwendet. Alte OTPs sind effektiv ungültig — deren Einreichung gibt 401 zurück.

Session-Token — SHA-256 + Widerruf ​

php
// Session-Token ausstellen
$rawToken = bin2hex(random_bytes(32));   // 256-Bit-Entropie, 64 Hex-Zeichen
$tokenHash = hash('sha256', $rawToken);
$this->repository->createSession((int) $user['id'], $tokenHash, $now);

return $this->responseFactory->create([
    'session_token' => $rawToken,
    'user_id' => (int) $user['id'],
], 200);

Nur der SHA-256-Hash wird gespeichert. Bei einem DB-Kompromiss werden Rohtoken niemals exponiert.

Bearer-Token-Extraktion ​

php
private function extractBearerToken(ServerRequestInterface $request): string
{
    $header = $request->getHeaderLine('Authorization');
    if (!str_starts_with($header, 'Bearer ')) {
        return '';
    }
    return trim(substr($header, 7));
}

Ein leerer String nach Bearer (z.B. Authorization: Bearer ) wird als fehlend behandelt — gibt 401 zurück.

Logout — Stiller Erfolg ​

php
$session = $this->repository->findSessionByTokenHash($tokenHash);
if ($session !== null && $session['revoked_at'] === null) {
    $this->repository->revokeSession($tokenHash, date('c'));
}

return $this->responseFactory->create(['message' => 'logged out'], 200);

Logout gibt immer 200 zurück — verrät nicht, ob das Token gültig war. Dies verhindert, dass Angreifer Token-Gültigkeit über den Logout-Endpunkt sondieren.


ATK Assessment — Cracker-Mindset-Angriffstest ​

ATK-01 — OTP-Brute-Force 🚫 BLOCKED ​

Angriff: Alle 000000–999999 Kombinationen sequentiell ausprobieren. Ergebnis: BLOCKED — nach MAX_ATTEMPTS (3) falschen Codes wird locked_until 10 Minuten in die Zukunft gesetzt. Nachfolgende Versuche geben 429 zurück, bis die Sperre abläuft.


ATK-02 — Replay-Angriff (verwendetes OTP wiederverwenden) 🚫 BLOCKED ​

Angriff: Ein gültiges OTP aufzeichnen und es ein zweites Mal einreichen, nachdem es bereits verwendet wurde. Ergebnis: BLOCKED — used_at wird bei der ersten erfolgreichen Verifizierung gesetzt. Ein zweiter Versuch findet used_at !== null → 401.


ATK-03 — Benutzer-Enumeration via /otp/request 🚫 BLOCKED ​

Angriff: /otp/request mit bekannten und unbekannten E-Mails sondieren, um herauszufinden, welche Konten existieren. Ergebnis: BLOCKED — Sowohl vorhandene als auch nicht vorhandene E-Mails geben immer 202 Accepted mit identischen Response-Bodies zurück.


ATK-04 — Verifizierung für nicht existierenden Benutzer 🚫 BLOCKED ​

Angriff: /otp/verify mit einer E-Mail aufrufen, die kein Konto hat. Ergebnis: BLOCKED — gibt 401 (invalid code) zurück, nicht 404 oder 500. Kein Stack-Trace oder Konto-Existenz-Signal in der Antwort.


ATK-05 — SQL-Injection im E-Mail-Feld 🚫 BLOCKED ​

Angriff: '; DROP TABLE users; -- als E-Mail einreichen. Ergebnis: BLOCKED — filter_var($email, FILTER_VALIDATE_EMAIL) lehnt Injection-Strings als ungültiges E-Mail-Format ab, bevor eine DB-Abfrage erfolgt. Alle Abfragen verwenden parametrisierte Statements.


ATK-06 — 5-stelliger Code (zu kurz) 🚫 BLOCKED ​

Angriff: Einen 5-Zeichen-Code einreichen, um die OTP-Formatprüfung zu umgehen. Ergebnis: BLOCKED — /^\d{6}$/ erfordert genau 6 Ziffern. Gibt 422 zurück.


ATK-07 — 7-stelliger Code (zu lang) 🚫 BLOCKED ​

Angriff: Einen 7-stelligen Code einreichen, um die Formatvalidierung zu umgehen. Ergebnis: BLOCKED — Dieselbe Regex lehnt Codes ab, die nicht genau 6 Ziffern sind. Gibt 422 zurück.


ATK-08 — Session-Token-Wiederverwendung nach Logout 🚫 BLOCKED ​

Angriff: Ein Token nach dem Logout verwenden, um den Zugriff aufrechtzuerhalten. Ergebnis: BLOCKED — revokeSession() setzt revoked_at. Der GET-Handler prüft $session['revoked_at'] !== null → 401.


ATK-09 — Zufälliges Token-Raten 🚫 BLOCKED ​

Angriff: Einen zufälligen 64-Hex-String als Bearer-Token einreichen. Ergebnis: BLOCKED — SHA-256-Hash des zufälligen Tokens stimmt mit keinem session_token_hash überein. Gibt 401 zurück. Token-Raum ist 2^256.


ATK-10 — Leeres Bearer-Token 🚫 BLOCKED ​

Angriff: Authorization: Bearer senden (leer nach Bearer-Präfix). Ergebnis: BLOCKED — trim(substr($header, 7)) gibt leeren String zurück → if ($token === '') return 401.


ATK-11 — Alphabetischer Code (nicht-numerisch) 🚫 BLOCKED ​

Angriff: abcdef als OTP-Code einreichen. Ergebnis: BLOCKED — /^\d{6}$/ erfordert nur Dezimalziffern. Gibt 422 zurück, bevor DB-Interaktion.


ATK-12 — Neue OTP-Anfrage macht alten Code ungültig 🚫 BLOCKED (by design) ​

Angriff: Gültiges OTP erhalten, Opfer lässt neues OTP anfordern, dann ursprünglichen Code einreichen. Ergebnis: BLOCKED — findLatestOtpForUser() ruft nur ORDER BY id DESC LIMIT 1 ab. Das alte OTP ist überholt; seine Einreichung gibt 401 zurück (falscher Code-Hash für das neueste OTP).


ATK-Zusammenfassung ​

IDAngriffErgebnis
ATK-01OTP-Brute-Force🚫 BLOCKED
ATK-02Replay-Angriff (verwendetes OTP)🚫 BLOCKED
ATK-03Benutzer-Enumeration via /otp/request🚫 BLOCKED
ATK-04Verifizierung nicht existierender Benutzer🚫 BLOCKED
ATK-05SQL-Injection in E-Mail🚫 BLOCKED
ATK-065-stelliger Code (zu kurz)🚫 BLOCKED
ATK-077-stelliger Code (zu lang)🚫 BLOCKED
ATK-08Session-Wiederverwendung nach Logout🚫 BLOCKED
ATK-09Zufälliges Token-Raten🚫 BLOCKED
ATK-10Leeres Bearer-Token🚫 BLOCKED
ATK-11Alphabetischer Code🚫 BLOCKED
ATK-12Altes OTP durch neue Anfrage ungültig🚫 BLOCKED

12 BLOCKED, 0 EXPOSED Hash-basierte Speicherung, Brute-Force-Sperrung, used_at-Replay-Schutz, Formatvalidierung und Always-202-Enumerationsprävention decken alle kritischen OTP-Angriffsvektoren ab.


Was man NICHT tun sollte ​

Anti-MusterRisiko
Rohcode des OTP in DB speichernDB-Kompromiss exponiert alle aktiven OTPs; immer SHA-256 hashen
Keine Brute-Force-Sperrung6-stelliges OTP hat 10^6 Kombinationen — ohne Sperrung in Sekunden knackbar
404 für unbekannte E-Mail bei Verifizierung zurückgebenVerrät, welche E-Mails Konten haben (Benutzer-Enumeration)
Unterschiedlichen Status für bekannte vs. unbekannte E-Mail bei /request zurückgebenGleiches Enumerationsrisiko; immer 202 zurückgeben
Kein used_at-FlagOTP kann unbegrenzt bis zum Ablauf wiederholt werden
Alphabetische oder nicht-6-stellige Codes akzeptierenUmgeht Formatvertrag; /^\d{6}$/-Prüfung hinzufügen
Rohes Session-Token in DB speichernDB-Verletzung exponiert alle Sessions; nur SHA-256-Hash speichern
Session-Zeile beim Logout löschenWiderrufene Tokens können nicht erkannt werden; revoked_at für Soft-Widerruf verwenden
Logout-Erfolg/Fehler basierend auf Token-Gültigkeit verratenAngreifer sondieren Token-Gültigkeit via Logout; immer 200 zurückgeben
findAllOtpsForUser() verwenden und gültiges auswählenMehrere aktive OTPs verwirren den Zustand; ORDER BY id DESC LIMIT 1 verwenden
Keine E-Mail-LängenbegrenzungRFC 5321 Maximum ist 254 Zeichen; überdimensionierter Input verursacht DB/E-Mail-Probleme

Veröffentlicht unter der MIT-Lizenz.