Skip to content

How-to: Einladungssystem

FT-Referenz: FT283 (NENE2-FT/invitelog) — Einladungscode-System: 32-Zeichen-Hex-Token (128-Bit-Entropie), ISO 8601-Datums-/Zeit-Validierung, pending→used-Status-Lebenszyklus, match-Expression-Statuszuordnung, IDOR-geschützte Einladungsliste, 23 Tests / 47 Assertions PASS.

Diese Anleitung zeigt, wie ein sicheres Einladungssystem aufgebaut wird — Einmaltokens generieren, die bei Einlösung Zugriff gewähren.

Anwendungsfall

Ein Benutzer erstellt einen Einladungslink (Token) und teilt ihn. Der Empfänger löst das Token ein, um beizutreten. Jedes Token ist für die einmalige Nutzung gedacht und zeitlich begrenzt.

Schema

sql
CREATE TABLE IF NOT EXISTS invitations (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    token       TEXT    NOT NULL UNIQUE,
    inviter_id  INTEGER NOT NULL,
    invitee_id  INTEGER,
    status      TEXT    NOT NULL DEFAULT 'pending',
    expires_at  TEXT    NOT NULL,
    used_at     TEXT,
    created_at  TEXT    NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_invitations_token ON invitations (token);
CREATE INDEX IF NOT EXISTS idx_invitations_inviter ON invitations (inviter_id, id DESC);

Wichtige Punkte:

  • token TEXT UNIQUE — erzwingt ein Token pro Zeile auf DB-Ebene
  • invitee_id ist NULL bis zur Einlösung
  • status'pending' | 'used'
  • used_at — wird bei Einlösung gesetzt, liefert Audit-Zeitstempel

Endpunkte

MethodePfadAuthBeschreibung
POST/invitationsX-User-IdEinladung erstellen
GET/invitations/{token}KeineEinladung nach Token nachschlagen
POST/invitations/{token}/useX-User-IdEinladung einlösen
GET/users/{userId}/invitationsX-User-Id (nur Selbst)Eigene Einladungen auflisten

Token-Generierung

php
/** Token: 32 Kleinbuchstaben-Hex-Zeichen (16 zufällige Bytes = 128-Bit-Entropie) */
public const string TOKEN_PATTERN = '/\A[0-9a-f]{32}\z/';

$token = bin2hex(random_bytes(16));

random_bytes(16) generiert kryptografisch sichere 128-Bit-Zufallsdaten. Die Hex-Darstellung besteht aus 32 Zeichen. Dies ist dasselbe Entropieniveau wie UUID v4 (122 nutzbare Bits).

expires_at-Validierung

php
private const string ISO_DATE_PATTERN = '/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/';

$expiresAt = trim((string) ($body['expires_at'] ?? ''));
if (!preg_match(self::ISO_DATE_PATTERN, $expiresAt)) {
    return $this->problem(422, 'validation-failed', 'expires_at must be ISO 8601 datetime.');
}

Der Regex validiert nur das Format. Die Zeitzonenbehandlung (UTC vs. lokal) liegt in der Verantwortung der Anwendung — die Verwendung einer einheitlichen Zeitzone (z.B. UTC) vermeidet Grenzfälle.

Status-Lebenszyklus

pending → used (einseitig, irreversibel)

Nur pending-Einladungen können eingelöst werden. Einmal genutzt, ist die Einladung dauerhaft verbraucht.

Einlösung mit match-Expression

php
$result = $this->repo->use($token, $uid);

return match ($result) {
    'not_found'    => $this->problem(404, 'not-found', 'Invitation not found.'),
    'already_used' => $this->problem(409, 'conflict', 'Invitation already used.'),
    'expired'      => $this->problem(409, 'conflict', 'Invitation has expired.'),
    default        => $this->json(['message' => 'Invitation accepted.']),
};

match ist erschöpfend (im Gegensatz zu switch): kein Fall-through, alle Fälle müssen behandelt werden. Das Repository gibt einen String-Ergebnistyp zurück; der Handler ordnet ihn sauber HTTP-Antworten zu.

Repository — Atomare Einlösung

php
/** @return 'ok'|'not_found'|'already_used'|'expired' */
public function use(string $token, int $inviteeId): string
{
    $inv = $this->findByToken($token);
    if ($inv === null) {
        return 'not_found';
    }
    if ($inv['status'] === 'used') {
        return 'already_used';
    }
    // Ablauf prüfen
    $now = $this->now();
    if ($inv['expires_at'] < $now) {
        return 'expired';
    }

    // Als genutzt markieren
    $this->pdo->prepare('UPDATE invitations SET status = \'used\', invitee_id = ?, used_at = ? WHERE token = ?')
        ->execute([$inviteeId, $now, $token]);

    return 'ok';
}

Die Prüf-dann-Aktualisieren-Sequenz ist eine potenzielle TOCTOU-Race-Condition bei gleichzeitigen Einlösungen desselben Tokens. Für die Produktion eine DB-Level-Transaktion oder UPDATE WHERE status = 'pending' verwenden und betroffene Zeilen prüfen.

IDOR — Einladungsliste

Nur der Einladende kann seine eigenen Einladungen einsehen:

php
$callerUid = $this->uid($req);
if ($callerUid !== $targetUid) {
    return $this->problem(404, 'not-found', 'User not found.');
}

Gibt 404 zurück (nicht 403), um zu verbergen, ob der Zielbenutzer existiert.

X-User-Id-Header-Validierung

php
private function uid(ServerRequestInterface $req): ?int
{
    $raw = $req->getHeaderLine('X-User-Id');
    if ($raw === '' || !ctype_digit($raw) || strlen($raw) > 18) {
        return null;
    }
    $id = (int) $raw;
    return $id > 0 ? $id : null;
}

ctype_digit() ist ReDoS-sicher; strlen > 18 verhindert PHP-Int-Überlauf auf 64-Bit; > 0 lehnt Benutzer-ID 0 ab.


Was man NICHT tun sollte

Anti-MusterRisiko
Kurze/sequenzielle Token verwenden (4-stelliger PIN)In Millisekunden per Brute-Force knackbar; mindestens 128-Bit-Zufall verwenden
Token ohne UNIQUE-Constraint speichernDoppeltes Token-Collision verursacht Verwirrung bei der Einlösung
status === 'pending' mit losem Vergleich prüfenPHP '0' == false; immer striktes === verwenden
Keine Ablaufvalidierung bei EinlösungAbgelaufene Einladungen bleiben dauerhaft einlösbar
403 bei Einladungslisten-IDOR-Prüfung zurückgebenEnthüllt, dass Zielbenutzer existiert; 404 verwenden, um Enumeration zu verbergen
Atomare Einlösung ohne TransaktionGleichzeitige Anfragen können beide pending sehen und beide erfolgreich sein — Doppeleinlösung
Soft Delete (deleted_at) statt Status-SpalteStatus-Spalte ist selbstdokumentierend; pending/used ist klarer als null/nicht-null
Beliebige Strings als expires_at akzeptierenSQL-injectable wenn nicht parametrisiert; parametrisierte Abfrage + Formatvalidierung verwenden
Status bei abgelaufenem Token auf pending zurücksetzenErmöglicht die Wiederverwendung von legitim abgelaufenen Tokens

Veröffentlicht unter der MIT-Lizenz.