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
composer require jardissupport/authGitHub: jardisSupport/auth
Session-Management
Sessions erstellen
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
$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
// Einzelne Session (Logout)
$event = $sessionManager->invalidate($session);
// SessionInvalidated
// Alle Sessions eines Subjects (Logout Everywhere)
$event = $sessionManager->invalidateAll('user:42');
// AllSessionsInvalidatedToken-Verifikation
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:
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)
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
$hasher = PasswordHasher::bcrypt(cost: 12);
$hash = $hasher->hash('s3cret!'); // $2y$12$...Rehash bei Login
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:
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
Credential::password('john@example.com', 's3cret!'); // Login
Credential::apiKey('service-a', 'key-abc123'); // API-Key
Credential::token('bearer-token-value'); // Token-basiertDer PasswordAuthenticator verarbeitet nur CredentialType::Password. Für andere Typen kann ein eigener Authenticator implementiert werden.
RBAC — Guard
Policy definieren
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
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 erlaubtPermission-Format
Permissions folgen dem Schema resource:action:
| Permission | Matcht |
|---|---|
article:read | Exakt article:read |
article:* | Alle Aktionen auf article |
* | Alles |
Multi-Role-Sessions
$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
$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:
| Event | Ausgelöst durch | Properties |
|---|---|---|
SessionCreated | create(), refresh() | subject, tokenHash, timestamp |
SessionRefreshed | refresh() | subject, oldTokenHash, newTokenHash, timestamp |
SessionInvalidated | invalidate() | subject, tokenHash, timestamp |
AllSessionsInvalidated | invalidateAll() | subject, timestamp |
AuthenticationSucceeded | authenticate() | subject, timestamp |
AuthenticationFailed | authenticate() | credentialType, reason, timestamp |
// Events an den EventDispatcher weiterleiten
foreach ($result->events as $event) {
$dispatcher->dispatch($event);
}Fehlerbehandlung
| Exception | Ursache |
|---|---|
AuthenticationException | Ungültiger Refresh Token |
TokenExpiredException | Token-TTL abgelaufen |
TokenRevokedException | Token wurde revoziert |
InvalidCredentialException | Falsche Credentials (für Custom-Authenticatoren) |
UnauthorizedException | RBAC-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.phpAPI-Referenz
SessionManager
| Methode | Signatur | Beschreibung |
|---|---|---|
create | create(Subject $subject, array $claims = []): SessionResult | Session erstellen |
refresh | refresh(string $refreshToken): SessionResult | Token-Rotation |
invalidate | invalidate(SessionInterface $session): SessionInvalidated | Logout |
invalidateAll | invalidateAll(string $subject): AllSessionsInvalidated | Logout Everywhere |
PasswordHasher
| Methode | Signatur | Beschreibung |
|---|---|---|
argon2id | static argon2id(int $memoryCost, int $timeCost, int $threads): self | Argon2id Factory |
bcrypt | static bcrypt(int $cost): self | Bcrypt Factory |
hash | hash(string $password): string | Passwort hashen |
verify | verify(string $password, string $hash): bool | Passwort prüfen |
needsRehash | needsRehash(string $hash): bool | Rehash nötig? |
PasswordAuthenticator
| Methode | Signatur | Beschreibung |
|---|---|---|
authenticate | authenticate(CredentialInterface $credential): AuthenticationResult | Login-Flow |
Guard
| Methode | Signatur | Beschreibung |
|---|---|---|
check | check(SessionInterface $session, string $permission): bool | Soft-Check |
authorize | authorize(SessionInterface $session, string $permission): void | Hard-Check |
Vollständiges Beispiel
Login, Session-Nutzung und RBAC in einer Anwendung:
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);