Skip to content

Auth

Vier Klassen, opake Tokens, RBAC als Value Objects, Authentifizierung ohne Framework-Kopplung.

Einführung

Authentifizierung in PHP-Anwendungen bedeutet oft: ein Framework-spezifisches Auth-System mit Sessions in $_SESSION, JWTs mit Algorithmus-Konfusion-Risiken und RBAC in der Datenbank. Wer das Auth-System wechseln will, baut die halbe Anwendung um.

jardissupport/auth geht einen anderen Weg. Vier Orchestratoren (SessionManager, PasswordHasher, PasswordAuthenticator, Guard), die zusammen oder unabhängig voneinander eingesetzt werden können:

  • Opake Tokens statt JWT — Random Bytes, serverseitig als SHA-256-Hash gespeichert. Keine Signatur-Verifikation, keine Algorithm-Confusion, kein Payload-Decoding
  • Token-Rotation — jeder Refresh revoziert den alten Token und gibt ein neues Paar aus
  • RBAC als Value Objects — Policies werden in Code gebaut, nicht aus der Datenbank geladen. Immutabel, testbar, versionierbar
  • Events returned, not dispatched — jede mutierende Operation gibt Domain Events zurück. Der Aufrufer entscheidet, ob und wie sie dispatched werden
  • Null Laufzeitabhängigkeiten — nur PHP-Builtins: password_hash, random_bytes, hash_equals, hash_hmac

Installation

bash
composer require jardissupport/auth

GitHub: jardisSupport/auth

Session-Management

Sessions erstellen

php
use JardisSupport\Auth\SessionManager;
use JardisSupport\Auth\Data\Subject;

$sessionManager = new SessionManager(
    tokenStore: $tokenStore,        // TokenStoreInterface — eigene Implementierung
    accessTokenTtl: 3600,           // 1 Stunde
    refreshTokenTtl: 604800,        // 7 Tage
);

$subject = Subject::from('42', 'user');
$result = $sessionManager->create($subject, ['role' => 'editor', 'tenant' => 'acme']);

$result->accessToken;    // Plain-Text Token (64 Hex-Zeichen)
$result->refreshToken;   // Plain-Text Refresh Token
$result->session;        // Session-Objekt mit Metadaten
$result->events;         // [SessionCreated]

Der Plain-Text-Token wird genau einmal zurückgegeben. Gespeichert wird nur der SHA-256-Hash. Ein kompromittierter Token-Store liefert keine brauchbaren Tokens.

Token-Refresh mit Rotation

php
$refreshed = $sessionManager->refresh($result->refreshToken);

$refreshed->accessToken;    // Neuer Access Token
$refreshed->refreshToken;   // Neuer Refresh Token (alter ist revoziert)
$refreshed->events;         // [SessionCreated, SessionRefreshed]

Der alte Refresh Token wird sofort revoziert. Ein gestohlener Refresh Token funktioniert nur einmal: beim zweiten Versuch wird TokenRevokedException geworfen.

Sessions invalidieren

php
// Einzelne Session (Logout)
$event = $sessionManager->invalidate($session);
// SessionInvalidated

// Alle Sessions eines Subjects (Logout Everywhere)
$event = $sessionManager->invalidateAll('user:42');
// AllSessionsInvalidated

Token-Verifikation

php
use JardisSupport\Auth\Handler\Token\VerifyToken;
use JardisSupport\Auth\Exception\TokenExpiredException;
use JardisSupport\Auth\Exception\TokenRevokedException;

$verifier = new VerifyToken();

try {
    $valid = $verifier($plainToken, $storedHashedToken, TokenType::Access);
} catch (TokenRevokedException) {
    // Token wurde revoziert (Logout)
} catch (TokenExpiredException) {
    // TTL abgelaufen → Refresh Token verwenden
}

TokenStoreInterface

Die Implementierung des Token-Stores ist Aufgabe der Anwendung. Das Interface:

php
interface TokenStoreInterface
{
    public function store(HashedTokenInterface $token): void;
    public function find(string $hash): ?HashedTokenInterface;
    public function revoke(string $hash): void;
    public function revokeAllForSubject(string $subject): void;
    public function deleteExpired(): int;
}

Typische Implementierungen: Redis (TTL-basiert), Datenbank (mit Cleanup-Job), oder In-Memory für Tests.

Password-Hashing

Argon2id (Standard)

php
use JardisSupport\Auth\PasswordHasher;

$hasher = PasswordHasher::argon2id(
    memoryCost: 65536,
    timeCost: 4,
    threads: 1,
);

$hash = $hasher->hash('s3cret!');        // $argon2id$...
$hasher->verify('s3cret!', $hash);       // true
$hasher->needsRehash($hash);             // false (Optionen unverändert)

Bcrypt

php
$hasher = PasswordHasher::bcrypt(cost: 12);

$hash = $hasher->hash('s3cret!');        // $2y$12$...

Rehash bei Login

php
if ($hasher->verify($password, $storedHash)) {
    if ($hasher->needsRehash($storedHash)) {
        $newHash = $hasher->hash($password);
        // Neuen Hash persistieren
    }
}

Password-Authentifizierung

Der PasswordAuthenticator kombiniert User-Lookup, Passwort-Verifikation und Session-Erstellung in einem Aufruf:

php
use JardisSupport\Auth\PasswordAuthenticator;
use JardisSupport\Auth\Data\Credential;
use JardisSupport\Auth\Data\Subject;

$authenticator = new PasswordAuthenticator(
    passwordHasher: $hasher,
    sessionManager: $sessionManager,
    userLookup: function (string $identifier): ?array {
        $user = $userRepository->findByEmail($identifier);
        if ($user === null) {
            return null;
        }
        return [
            'hash'    => $user->getPasswordHash(),
            'subject' => Subject::from($user->getId(), 'user'),
            'claims'  => ['role' => $user->getRole(), 'tenant' => $user->getTenantId()],
        ];
    },
);

$result = $authenticator->authenticate(
    Credential::password('john@example.com', 's3cret!')
);

if ($result->isSuccess()) {
    $result->getSubject();          // 'user:42'
    $result->session->metadata;     // ['role' => 'admin', 'tenant' => 'acme']
    $result->accessToken;           // Plain-Text Access Token
    $result->refreshToken;          // Plain-Text Refresh Token
    $result->events;                // [SessionCreated, AuthenticationSucceeded]
} else {
    $result->getReason();           // 'Invalid credentials'
    $result->events;                // [AuthenticationFailed]
}

Sichere Fehlermeldungen

getReason() gibt immer "Invalid credentials" zurück, unabhängig davon, ob der User nicht gefunden wurde oder das Passwort falsch war. Die spezifische Ursache steht nur im internen AuthenticationFailed-Event (für Logging).

Credential-Typen

php
Credential::password('john@example.com', 's3cret!');   // Login
Credential::apiKey('service-a', 'key-abc123');          // API-Key
Credential::token('bearer-token-value');                // Token-basiert

Der PasswordAuthenticator verarbeitet nur CredentialType::Password. Für andere Typen kann ein eigener Authenticator implementiert werden.

RBAC — Guard

Policy definieren

php
use JardisSupport\Auth\Data\Policy;

$policy = Policy::create()
    ->role('viewer')
        ->allow('article:read', 'comment:read')
    ->role('editor')
        ->allow('article:read', 'article:write', 'article:publish')
        ->deny('article:delete')
    ->role('moderator')
        ->includes('editor')              // Erbt Editor-Permissions
        ->allow('comment:delete')
    ->role('admin')
        ->allow('*')                      // Wildcard: alles erlaubt
    ->build();

Permissions prüfen

php
use JardisSupport\Auth\Guard;

$guard = new Guard($policy);

// Soft-Check (bool)
$guard->check($session, 'article:write');    // true/false

// Hard-Check (Exception)
$guard->authorize($session, 'article:delete');
// → UnauthorizedException wenn nicht erlaubt

Permission-Format

Permissions folgen dem Schema resource:action:

PermissionMatcht
article:readExakt article:read
article:*Alle Aktionen auf article
*Alles

Multi-Role-Sessions

php
$session = new Session(
    subject: 'user:1',
    tokenHash: hash('sha256', 'token'),
    createdAt: new DateTimeImmutable(),
    expiresAt: null,
    metadata: ['role' => ['viewer', 'moderator']],
);

$guard->check($session, 'comment:delete');   // true (Moderator)
$guard->check($session, 'article:write');    // true (Moderator erbt Editor)

Die Rolle wird aus $session->getMetadata()['role'] gelesen: als string (Single Role) oder array<string> (Multi Role). Die erste Rolle, die die Permission gewährt, gewinnt.

Role Inheritance

php
$policy = Policy::create()
    ->role('editor')
        ->allow('article:read', 'article:write')
        ->deny('article:delete')
    ->role('moderator')
        ->includes('editor')
        ->allow('comment:delete')
    ->build();

$policy->isAllowed('moderator', 'article:write');    // true  (geerbt)
$policy->isAllowed('moderator', 'article:delete');   // false (deny im Editor)
$policy->isAllowed('moderator', 'comment:delete');   // true  (eigene Permission)

Deny gilt pro Rolle

deny() wirkt nur innerhalb der Rolle, in der es definiert ist. Eine Rolle, die eine andere Rolle per includes() erbt, erbt auch deren deny-Regeln, aber die Auswertung erfolgt immer im Kontext der jeweiligen Rolle.

Domain Events

Jede mutierende Operation gibt Events zurück. Der Aufrufer entscheidet über die Weiterverarbeitung:

EventAusgelöst durchProperties
SessionCreatedcreate(), refresh()subject, tokenHash, timestamp
SessionRefreshedrefresh()subject, oldTokenHash, newTokenHash, timestamp
SessionInvalidatedinvalidate()subject, tokenHash, timestamp
AllSessionsInvalidatedinvalidateAll()subject, timestamp
AuthenticationSucceededauthenticate()subject, timestamp
AuthenticationFailedauthenticate()credentialType, reason, timestamp
php
// Events an den EventDispatcher weiterleiten
foreach ($result->events as $event) {
    $dispatcher->dispatch($event);
}

Fehlerbehandlung

ExceptionUrsache
AuthenticationExceptionUngültiger Refresh Token
TokenExpiredExceptionToken-TTL abgelaufen
TokenRevokedExceptionToken wurde revoziert
InvalidCredentialExceptionFalsche Credentials (für Custom-Authenticatoren)
UnauthorizedExceptionRBAC-Check fehlgeschlagen

Architektur

Vier Orchestratoren im Closure-Orchestrator-Pattern, jeder delegiert komplett an Handler:

SessionManager                         ← Orchestrator
├── CreateSession                      ← Token generieren + speichern
├── RefreshSession                     ← Alten Token revozieren, neues Paar
├── InvalidateSession                  ← Einzelnen Token revozieren
└── InvalidateAllSessions              ← Alle Tokens eines Subjects

PasswordHasher                         ← Orchestrator
├── HashPassword                       ← password_hash()
├── VerifyPassword                     ← password_verify()
└── CheckRehash                        ← password_needs_rehash()

PasswordAuthenticator                  ← Orchestrator
├── LookupUser                         ← User-Lookup Closure
├── VerifyCredential                   ← Passwort-Verifikation
└── BuildAuthResult                    ← Result zusammenbauen

Guard                                  ← Orchestrator
├── CheckPermission                    ← RBAC-Check (bool)
└── AuthorizePermission                ← RBAC-Enforce (Exception)

Verzeichnisstruktur

src/
├── SessionManager.php
├── PasswordHasher.php
├── PasswordAuthenticator.php
├── Guard.php
├── Data/
│   ├── Session.php
│   ├── SessionResult.php
│   ├── Subject.php
│   ├── Token.php
│   ├── HashedToken.php
│   ├── Credential.php
│   ├── AuthResult.php
│   ├── AuthenticationResult.php
│   ├── Permission.php
│   ├── Policy.php
│   ├── PolicyBuilder.php
│   └── Event/
│       ├── SessionCreated.php
│       ├── SessionRefreshed.php
│       ├── SessionInvalidated.php
│       ├── AllSessionsInvalidated.php
│       ├── AuthenticationSucceeded.php
│       └── AuthenticationFailed.php
├── Handler/
│   ├── Session/
│   ├── Token/
│   ├── Password/
│   ├── Authentication/
│   └── Authorization/
└── Exception/
    ├── AuthenticationException.php
    ├── TokenExpiredException.php
    ├── TokenRevokedException.php
    ├── InvalidCredentialException.php
    └── UnauthorizedException.php

API-Referenz

SessionManager

MethodeSignaturBeschreibung
createcreate(Subject $subject, array $claims = []): SessionResultSession erstellen
refreshrefresh(string $refreshToken): SessionResultToken-Rotation
invalidateinvalidate(SessionInterface $session): SessionInvalidatedLogout
invalidateAllinvalidateAll(string $subject): AllSessionsInvalidatedLogout Everywhere

PasswordHasher

MethodeSignaturBeschreibung
argon2idstatic argon2id(int $memoryCost, int $timeCost, int $threads): selfArgon2id Factory
bcryptstatic bcrypt(int $cost): selfBcrypt Factory
hashhash(string $password): stringPasswort hashen
verifyverify(string $password, string $hash): boolPasswort prüfen
needsRehashneedsRehash(string $hash): boolRehash nötig?

PasswordAuthenticator

MethodeSignaturBeschreibung
authenticateauthenticate(CredentialInterface $credential): AuthenticationResultLogin-Flow

Guard

MethodeSignaturBeschreibung
checkcheck(SessionInterface $session, string $permission): boolSoft-Check
authorizeauthorize(SessionInterface $session, string $permission): voidHard-Check

Vollständiges Beispiel

Login, Session-Nutzung und RBAC in einer Anwendung:

php
use JardisSupport\Auth\Guard;
use JardisSupport\Auth\PasswordAuthenticator;
use JardisSupport\Auth\PasswordHasher;
use JardisSupport\Auth\SessionManager;
use JardisSupport\Auth\Data\Credential;
use JardisSupport\Auth\Data\Policy;
use JardisSupport\Auth\Data\Subject;

// Setup
$hasher = PasswordHasher::argon2id();
$sessionManager = new SessionManager($tokenStore);

$authenticator = new PasswordAuthenticator(
    $hasher,
    $sessionManager,
    fn(string $id) => $userRepo->findForAuth($id),
);

$policy = Policy::create()
    ->role('editor')->allow('article:read', 'article:write')
    ->role('admin')->allow('*')
    ->build();

$guard = new Guard($policy);

// Login
$result = $authenticator->authenticate(
    Credential::password('john@example.com', 's3cret!')
);

if ($result->isSuccess()) {
    // Token an Client senden
    setcookie('access_token', $result->accessToken, httponly: true, secure: true);
    setcookie('refresh_token', $result->refreshToken, httponly: true, secure: true);

    // Events dispatchen
    foreach ($result->events as $event) {
        $dispatcher->dispatch($event);
    }
}

// Middleware: Token validieren und RBAC prüfen
$guard->authorize($session, 'article:write');
// → UnauthorizedException wenn nicht erlaubt

// Refresh
$refreshed = $sessionManager->refresh($refreshToken);
setcookie('access_token', $refreshed->accessToken, httponly: true, secure: true);
setcookie('refresh_token', $refreshed->refreshToken, httponly: true, secure: true);

// Logout
$sessionManager->invalidate($session);