Skip to content

How-to: RBAC + JWT-Authentifizierung

FT-Referenz: FT279 (NENE2-FT/rbaclog) — Rollenbasierte Zugriffskontrolle mit JWT: Argon2id-Passwort-Hashing mit Timing-Angriffs-Schutz, Rollen-Claim im JWT, Unterscheidung 401 vs. 403, BearerTokenMiddleware mit manuellem Fallback, 14 Tests / 48 Assertions bestanden.

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

Diese Anleitung zeigt, wie ein rollenbasiertes Zugriffskontrollsystem (RBAC) mit JWT-Tokens in NENE2 gebaut wird.

Funktionen

  • E-Mail + Passwort-Login (Argon2id-Hashing)
  • Rollen-Claim im JWT eingebettet (user / admin)
  • Öffentliche, authentifizierte und nur-Admin-Endpunkte
  • BearerTokenMiddleware mit per-Handler-Fallback
  • Korrekte 401 Unauthorized vs. 403 Forbidden-Semantik

Schema

sql
CREATE TABLE IF NOT EXISTS users (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    email         TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    role          TEXT NOT NULL DEFAULT 'user',
    created_at    TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS posts (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT NOT NULL,
    body       TEXT NOT NULL,
    author_id  INTEGER NOT NULL,
    created_at TEXT NOT NULL
);

Endpunkte

MethodePfadAuthBeschreibung
POST/auth/loginKeineAnmelden, JWT erhalten
GET/postsKeineAlle Beiträge auflisten (öffentlich)
POST/postsBenutzer oder AdminBeitrag erstellen
DELETE/posts/{id}Nur AdminBeitrag löschen

Login mit Timing-Angriffs-Schutz

Der Dummy-Hash-Trick stellt sicher, dass der Login immer gleich lang dauert, ob die E-Mail existiert oder nicht:

php
$user = $this->users->findByEmail(trim($body['email']));

$dummyHash   = '$argon2id$v=19$m=65536,t=4,p=1$dummysaltdummysaltdummysalt$dummyhashvaluedummyhashvaluedummyh';
$hashToCheck = $user !== null ? $user->passwordHash : $dummyHash;

if (!password_verify($body['password'], $hashToCheck) || $user === null) {
    return $this->problems->create($request, 'invalid-credentials', 'Ungültige Anmeldedaten', 401, '...');
}

Ohne den Dummy-Hash kann ein Timing-Angriff gültige E-Mail-Adressen erkennen, indem die Antwortzeit gemessen wird — die Hash-Berechnung wird für unbekannte E-Mails übersprungen.

Rollen-Claim im JWT

Die Rolle wird im JWT-Payload gespeichert, um einen DB-Roundtrip bei jeder Anfrage zu vermeiden:

php
$token = $this->issuer->issue([
    'sub'   => $user->id,
    'email' => $user->email,
    'role'  => $user->role->value,   // Role::User → 'user', Role::Admin → 'admin'
    'iat'   => $now,
    'exp'   => $now + self::TOKEN_TTL_SECONDS,
]);

Rollenprüfung mit Enum

php
private function requireRole(ServerRequestInterface $request, Role $required): array|ResponseInterface
{
    $claims = $this->requireAuth($request);
    if ($claims instanceof ResponseInterface) {
        return $claims;
    }

    $actualRole = Role::tryFrom((string) ($claims['role'] ?? ''));

    if ($actualRole !== $required) {
        return $this->problems->create(
            $request, 'forbidden', 'Verboten', 403,
            "Diese Aktion erfordert die Rolle '{$required->value}'."
        );
    }

    return $claims;
}

Role::tryFrom() ordnet den String-Claim sicher dem Enum zu — ungültige Rollen-Strings werden zu null, was die Prüfung scheitern lässt.

Unterscheidung 401 vs. 403

StatusBedeutungWann
401 UnauthorizedNicht authentifiziertKein Token, ungültiges Token, abgelaufenes Token
403 ForbiddenAuthentifiziert aber unzureichende RolleGültiges Token, falsche Rolle

Diese Unterscheidung ist für Clients wichtig: Ein 401 sollte eine erneute Anmeldung auslösen; ein 403 sollte eine "Zugriff verweigert"-Meldung anzeigen.

BearerTokenMiddleware mit Fallback

Einige Pfade bedienen sowohl öffentliche als auch geschützte Methoden (z.B. GET /posts ist öffentlich, POST /posts ist authentifiziert). Die Middleware schließt den Pfad vollständig aus, und Handler, die Auth benötigen, rufen requireAuth() manuell auf:

php
$authMiddleware = new BearerTokenMiddleware(
    problemDetails: $problems,
    verifier: $this->verifier,
    excludedPaths: ['/auth/login', '/posts'],  // /posts benötigt per-Methode-Behandlung
);
php
private function requireAuth(ServerRequestInterface $request): array|ResponseInterface
{
    // Schneller Pfad: Middleware hat bereits verifiziert
    $claims = $request->getAttribute('nene2.auth.claims');
    if (is_array($claims)) {
        return $claims;
    }

    // Langsamer Pfad: manuelle Extraktion für ausgeschlossene Pfade
    $authorization = $request->getHeaderLine('Authorization');
    if ($authorization === '' || !str_starts_with($authorization, 'Bearer ')) {
        return $this->problems->create($request, 'unauthorized', 'Nicht autorisiert', 401, '...');
    }

    try {
        return $this->verifier->verify(substr($authorization, 7));
    } catch (TokenVerificationException) {
        return $this->problems->create($request, 'unauthorized', 'Nicht autorisiert', 401, '...');
    }
}

VULN-Assessment — Schwachstellenanalyse

V-01 — Rollenerweiterung via gefälschtem JWT-Claim 🛡️ SAFE

Bedrohung: Angreifer erstellt einen JWT mit "role": "admin" und signiert ihn mit einem zufälligen Secret. Abwehr: LocalBearerTokenVerifier validiert die HMAC-HS256-Signatur gegen das Server-Secret. Ein nicht übereinstimmendes Secret verursacht TokenVerificationException → 401. Ergebnis: SAFE — Signaturverifizierung verhindert Claim-Fälschung.


V-02 — Timing-Angriff via E-Mail-Enumeration beim Login 🛡️ SAFE

Bedrohung: Angreifer sendet Login-Anfragen für unbekannte vs. bekannte E-Mails und misst die Antwortzeit zur Enumeration gültiger Konten. Abwehr: Für unbekannte E-Mails wird password_verify() gegen einen Dummy-Argon2id-Hash aufgerufen (gleiche Kostenparameter). Beide Pfade dauern ~200ms. Die Login-Fehlermeldung ist identisch für falsche E-Mail und falsches Passwort. Ergebnis: SAFE — Timing ist ausgeglichen; Fehlermeldung ist generisch.


V-03 — Abgelaufenes Token als gültig akzeptiert 🛡️ SAFE

Bedrohung: Angreifer verwendet ein erfasstes JWT nach Ablauf wieder. Abwehr: LocalBearerTokenVerifier prüft den exp-Claim gegen time(). Abgelaufene Tokens werfen TokenVerificationException → 401. Ergebnis: SAFE — exp-Prüfung wird durchgesetzt.


V-04 — Rollendegradierung durch Modifizieren des JWT-Payloads (ohne erneutes Signieren) 🛡️ SAFE

Bedrohung: Angreifer base64-dekodiert den JWT-Payload, ändert "role": "user" zu "role": "admin", kodiert erneut und sendet mit ursprünglicher Signatur. Abwehr: JWT-Signatur deckt Header + Payload ab. Das Modifizieren des Payloads macht die Signatur ungültig → TokenVerificationException → 401. Ergebnis: SAFE — Payload-Manipulation durch HMAC erkannt.


V-05 — Admin-Endpunkt mit Benutzerrolle zugänglich 🛡️ SAFE

Bedrohung: Angreifer meldet sich als user an und versucht DELETE /posts/{id}. Abwehr: requireRole($request, Role::Admin) prüft den JWT-role-Claim. Ein user-Token hat role: 'user'Role::tryFrom('user') !== Role::Admin → 403. Ergebnis: SAFE — 403 Forbidden zurückgegeben; Benutzer-Token kann sich nicht zu Admin erhöhen.


V-06 — Nicht-authentifizierter Zugriff auf geschützten Endpunkt 🛡️ SAFE

Bedrohung: Angreifer sendet POST /posts oder DELETE /posts/{id} ohne Authorization-Header. Abwehr: requireAuth() prüft auf Bearer -Präfix; fehlender Header → 401 unauthorized. Ergebnis: SAFE — 401 Unauthorized zurückgegeben.


V-07 — 401 vs. 403 Verwechslung (Informationsleck) 🛡️ SAFE

Bedrohung: Falsche 401/403-Verwendung gibt preis, ob eine Ressource existiert oder ob der Benutzer authentifiziert ist. Abwehr: System gibt 401 für nicht-authentifizierten Zugriff zurück (kein/ungültiges Token) und 403 für authentifizierten Zugriff mit unzureichender Rolle. Die Unterscheidung ist semantisch korrekt und gibt keine Ressourcenexistenz über die Rollenanforderung hinaus preis. Ergebnis: SAFE — 401/403-Semantik ist korrekt.


V-08 — Ungültiger Rollen-String im JWT-Bypass 🛡️ SAFE

Bedrohung: Angreifer erstellt einen JWT (mit gültigem Secret, z.B. kompromittiertes Secret-Szenario) und setzt role auf einen unbekannten Wert wie "superadmin". Abwehr: Role::tryFrom((string) ($claims['role'] ?? '')) gibt null für unbekannte Strings zurück → null !== Role::Admin → 403. Ergebnis: SAFE — tryFrom() ist null-sicher; unbekannte Rollen werden als unzureichend behandelt.


V-09 — SQL-Injection via E-Mail-Feld beim Login 🛡️ SAFE

Bedrohung: Angreifer sendet {"email": "' OR '1'='1", "password": "anything"}. Abwehr: findByEmail() verwendet parametrisierte Abfrage (WHERE email = ?). Der injizierte String wird als literaler Wert behandelt, nicht als SQL. Ergebnis: SAFE — parametrisierte Abfragen verhindern SQL-Injection.


V-10 — Passwort im Klartext gespeichert 🛡️ SAFE

Bedrohung: Bei einem DB-Einbruch sind Passwörter lesbar. Abwehr: password_hash($password, PASSWORD_ARGON2ID) mit Kostenparametern m=65536,t=4,p=1. Nur der Argon2id-Hash wird gespeichert; das Klartext-Passwort wird niemals persistiert. Ergebnis: SAFE — Argon2id ist der aktuell empfohlene Algorithmus (RFC 9106); PBKDF2/bcrypt/scrypt würden ebenfalls bestehen.


VULN-Zusammenfassung

IDBedrohungErgebnis
V-01Rollenerweiterung via gefälschtem JWT🛡️ SAFE
V-02Timing-Angriff via E-Mail-Enumeration🛡️ SAFE
V-03Abgelaufenes Token akzeptiert🛡️ SAFE
V-04JWT-Payload-Manipulation ohne erneutes Signieren🛡️ SAFE
V-05Admin-Endpunkt mit Benutzerrollen-Token🛡️ SAFE
V-06Nicht-authentifizierter Zugriff auf geschützten Endpunkt🛡️ SAFE
V-07401 vs. 403 Verwechslung🛡️ SAFE
V-08Unbekannter Rollen-String-Bypass🛡️ SAFE
V-09SQL-Injection via E-Mail-Feld🛡️ SAFE
V-10Passwort im Klartext gespeichert🛡️ SAFE

10 SAFE, 0 EXPOSED Argon2id-Hashing, HMAC-signiertes JWT, Role::tryFrom()-Guard und parametrisierte Abfragen verhindern alle getesteten Schwachstellenvektoren.


Was man NICHT tun sollte

Anti-MusterRisiko
Rolle in DB speichern und bei jeder Anfrage nachschlagenZusätzliche DB-Abfrage pro Anfrage; Rollenänderung erfordert Token-Widerruf-Logik
Role::from() statt Role::tryFrom() verwendenUnbekannte Rollen-Strings werfen ValueError — 500 statt 403
403 für nicht-authentifizierte Anfragen zurückgebenFührt Clients in die Irre — 403 sollte "authentifiziert aber verboten" bedeuten, nicht "nicht angemeldet"
401 für falschen Rollen-Zugriff zurückgebenClient versucht möglicherweise erneut anzumelden statt "Zugriff verweigert" anzuzeigen
Dummy-Hash beim Login überspringenTiming-Angriff gibt gültige E-Mail-Adressen preis
Passwörter als MD5/SHA1/Klartext speichernBrute-Force- oder Rainbow-Table-Angriffe setzen alle Passwörter bei einem DB-Einbruch frei
Berechtigungen (nicht Rollen) im JWT einbettenBerechtigungsänderungen erfordern Token-Neuausstellung; Rollen sind stabil, Berechtigungen ändern sich
alg: none JWT erlaubenAngreifer kann Tokens durch vollständiges Entfernen der Signatur fälschen
str_contains($role, 'admin') statt Enum-Prüfung verwenden"not-admin" oder "superadmin" könnten unerwartet übereinstimmen

Veröffentlicht unter der MIT-Lizenz.