# MagicLinks System Dokumentation des MagicLinks Moduls - Sichere, zeitlich begrenzte Links für verschiedene Aktionen. ## Übersicht Das MagicLinks System ermöglicht die Generierung und Verwaltung von sicheren, zeitlich begrenzten Links für verschiedene Anwendungsfälle wie Email-Verifizierung, Passwort-Reset, Dokumentenzugriff und mehr. ## Architektur ``` ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ Token System │───▶│ Service Layer │───▶│ Action System │ │ (Value Objects)│ │ (Storage) │ │ (Execution) │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │ │ MagicLinkToken MagicLinkService MagicLinkAction MagicLinkData CacheMagicLink... ActionRegistry TokenAction InMemoryMagicLink ActionResult TokenConfig ``` ## Kern-Komponenten ### 1. Token System **SmartLinkToken** - Value Object für Token: ```php final readonly class SmartLinkToken { public function __construct( public string $value // Min. 16 Zeichen ) {} public function equals(MagicLinkToken $other): bool { return hash_equals($this->value, $other->value); } } ``` **MagicLinkData** - Token-Daten: ```php final readonly class MagicLinkData { public function __construct( public string $id, public TokenAction $action, // Welche Action public array $payload, // Action-spezifische Daten public DateTimeImmutable $expiresAt, // Ablaufzeit public DateTimeImmutable $createdAt, public bool $oneTimeUse = false, // Einmalige Verwendung? public ?string $createdByIp = null, // Tracking public ?string $userAgent = null, public bool $isUsed = false, public ?DateTimeImmutable $usedAt = null ) {} public function isExpired(): bool; public function isValid(): bool; public function getRemainingTime(): \DateInterval; public function withUsed(DateTimeImmutable $usedAt): self; } ``` **TokenAction** - Action-Identifier: ```php final readonly class TokenAction { public function __construct( public string $value // z.B. 'email_verification', 'password_reset' ) {} } ``` **TokenConfig** - Token-Konfiguration: ```php final readonly class TokenConfig { public function __construct( public int $ttlSeconds = 3600, // Time-to-Live public bool $oneTimeUse = false, // Einmalige Verwendung public ?int $maxUses = null, // Max. Anzahl Verwendungen public bool $ipRestriction = false // IP-basierte Restriktion ) {} } ``` ### 2. Service Layer **MagicLinkService Interface**: ```php interface MagicLinkService { // Token generieren public function generate( TokenAction $action, array $payload, ?TokenConfig $config = null, ?string $createdByIp = null, ?string $userAgent = null ): MagicLinkToken; // Token validieren und Daten abrufen public function validate(MagicLinkToken $token): ?MagicLinkData; // Token als verwendet markieren public function markAsUsed(MagicLinkToken $token): void; // Token widerrufen public function revoke(MagicLinkToken $token): void; // Token-Existenz prüfen public function exists(MagicLinkToken $token): bool; // Admin: Aktive Tokens abrufen public function getActiveTokens(int $limit = 100): array; // Cleanup: Abgelaufene Tokens löschen public function cleanupExpired(): int; } ``` **Implementierungen**: - **CacheMagicLinkService** - Cache-basierte Implementierung (Production) - **InMemoryMagicLinkService** - In-Memory Implementierung (Testing) ### 3. Action System **MagicLinkAction Interface**: ```php interface MagicLinkAction { // Action-Name public function getName(): string; // Default-Konfiguration public function getDefaultConfig(): TokenConfig; // Payload validieren public function validatePayload(array $payload): bool; // Action ausführen public function execute(MagicLinkData $magiclinkData, array $context = []): ActionResult; // Erforderliche Permissions public function getRequiredPermissions(): array; } ``` **ActionResult** - Execution-Ergebnis: ```php final readonly class ActionResult { public function __construct( public bool $success, public string $message, public array $data = [], public array $errors = [], public ?string $redirectUrl = null ) {} public function isSuccess(): bool; public function hasRedirect(): bool; } ``` **ActionRegistry** - Action-Verwaltung: ```php interface ActionRegistry { public function register(MagicLinkAction $action): void; public function get(TokenAction $action): ?MagicLinkAction; public function has(TokenAction $action): bool; public function all(): array; } ``` ### 4. Vordefinierte Actions **EmailVerificationAction**: ```php final readonly class EmailVerificationAction implements MagicLinkAction { public function getName(): string { return 'email_verification'; } public function getDefaultConfig(): TokenConfig { return new TokenConfig( ttlSeconds: 86400, // 24 Stunden oneTimeUse: true // Einmalig verwendbar ); } public function validatePayload(array $payload): bool { return isset($payload['user_id']) && isset($payload['email']); } public function execute(MagicLinkData $magiclinkData, array $context = []): ActionResult { // Email-Verifizierungs-Logik } } ``` **PasswordResetAction**: ```php final readonly class PasswordResetAction implements MagicLinkAction { public function getName(): string { return 'password_reset'; } public function getDefaultConfig(): TokenConfig { return new TokenConfig( ttlSeconds: 3600, // 1 Stunde oneTimeUse: true, ipRestriction: true // IP-gebunden ); } } ``` **DocumentAccessAction** - Temporärer Dokumentenzugriff: ```php final readonly class DocumentAccessAction implements MagicLinkAction { public function getName(): string { return 'document_access'; } public function getDefaultConfig(): TokenConfig { return new TokenConfig( ttlSeconds: 3600, // 1 Stunde maxUses: 5 // Max. 5 Downloads ); } } ``` **GenericDataAccessAction** - Generischer Datenzugriff: ```php final readonly class GenericDataAccessAction implements MagicLinkAction { public function getName(): string { return 'data_access'; } } ``` ## Command/Handler Pattern **GenerateMagicLinkCommand**: ```php final readonly class GenerateMagicLinkCommand { public function __construct( public TokenAction $action, public array $payload, public string $baseUrl, public ?TokenConfig $config = null ) {} } ``` **GenerateMagicLinkHandler**: ```php final readonly class GenerateMagicLinkHandler { public function handle(GenerateMagicLinkCommand $command): string { // 1. Token generieren $token = $this->magiclinkService->generate( $command->action, $command->payload, $command->config ); // 2. URL zusammenbauen return $command->baseUrl . '/magiclink/' . $token->value; } } ``` **ExecuteMagicLinkCommand**: ```php final readonly class ExecuteMagicLinkCommand { public function __construct( public MagicLinkToken $token, public array $context = [] ) {} } ``` **ExecuteMagicLinkHandler**: ```php final readonly class ExecuteMagicLinkHandler { public function handle(ExecuteMagicLinkCommand $command): ActionResult { // 1. Token validieren $data = $this->magiclinkService->validate($command->token); // 2. Action holen und ausführen $action = $this->actionRegistry->get($data->action); $result = $action->execute($data, $command->context); // 3. Bei One-Time-Use: Als verwendet markieren if ($data->oneTimeUse && $result->isSuccess()) { $this->magiclinkService->markAsUsed($command->token); } return $result; } } ``` ## HTTP Controller **Route**: `/magiclink/{token}` (GET/POST) ```php final readonly class MagicLink { #[Route('/magiclink/{token}', method: Method::GET)] #[Route('/magiclink/{token}', method: Method::POST)] public function execute(string $token, Request $request): ActionResult { // 1. Token validieren $magiclinkToken = new MagicLinkToken($token); $magiclinkData = $this->magiclinkService->validate($magiclinkToken); if (!$magiclinkData) { return new ViewResult( template: 'magiclinks-error', data: ['error' => 'Ungültiger oder abgelaufener Link'] ); } // 2. Action holen $action = $this->actionRegistry->get($magiclinkData->action); // 3. Context vorbereiten $context = [ 'request_method' => $request->method->value, 'request_data' => $request->parsedBody ?? [], 'query_params' => $request->queryParameters ?? [], 'user_agent' => $request->headers->getFirst(HeaderKey::USER_AGENT), ]; // 4. Command ausführen $command = new ExecuteMagicLinkCommand($magiclinkToken, $context); $result = $this->commandBus->dispatch($command); // 5. Ergebnis verarbeiten if ($result->hasRedirect()) { return new Redirect($result->redirectUrl); } return new ViewResult( template: $action->getViewTemplate(), data: ['result' => $result] ); } } ``` ## Verwendung ### MagicLink generieren ```php use App\Framework\MagicLinks\Commands\GenerateMagicLinkCommand; use App\Framework\MagicLinks\TokenAction; use App\Framework\MagicLinks\TokenConfig; // 1. Command erstellen $command = new GenerateMagicLinkCommand( action: new TokenAction('email_verification'), payload: [ 'user_id' => 123, 'email' => 'user@example.com' ], baseUrl: 'https://example.com', config: new TokenConfig( ttlSeconds: 86400, // 24 Stunden oneTimeUse: true ) ); // 2. Via CommandBus dispatchen $url = $this->commandBus->dispatch($command); // 3. URL versenden (z.B. per Email) $this->mailer->send($user->email, $url); ``` ### Eigene Action implementieren ```php final readonly class InvitationAcceptAction implements MagicLinkAction { public function getName(): string { return 'invitation_accept'; } public function getDefaultConfig(): TokenConfig { return new TokenConfig( ttlSeconds: 604800, // 7 Tage oneTimeUse: true ); } public function validatePayload(array $payload): bool { return isset($payload['invitation_id']) && isset($payload['invitee_email']); } public function execute(MagicLinkData $magiclinkData, array $context = []): ActionResult { $invitationId = $magiclinkData->payload['invitation_id']; try { // Business Logic $this->invitationService->accept($invitationId); return new ActionResult( success: true, message: 'Einladung erfolgreich angenommen', redirectUrl: '/dashboard' ); } catch (\Exception $e) { return new ActionResult( success: false, message: 'Fehler beim Annehmen der Einladung', errors: [$e->getMessage()] ); } } public function getRequiredPermissions(): array { return []; // Öffentlich zugänglich } } ``` ### Action registrieren ```php // In MagicLinkInitializer final readonly class MagicLinkInitializer { #[Initializer] public function __invoke(): ActionRegistry { $registry = new DefaultActionRegistry(); // Vordefinierte Actions $registry->register(new EmailVerificationAction()); $registry->register(new PasswordResetAction()); $registry->register(new DocumentAccessAction()); // Custom Action $registry->register(new InvitationAcceptAction()); return $registry; } } ``` ## Service-Konfiguration ```php // In MagicLinkInitializer #[Initializer] public function init(): MagicLinkService { // Production: Cache-basiert return $this->container->get(CacheMagicLinkService::class); // Testing: In-Memory // return $this->container->get(InMemoryMagicLinkService::class); } ``` ## Sicherheitsaspekte ### Token-Generierung - **Min. 16 Zeichen** für Token-Sicherheit - **Kryptographisch sichere** Zufallstoken - **Hash-basierter Vergleich** via `hash_equals()` ### Token-Validierung - **Ablaufzeit-Prüfung** - `isExpired()` - **One-Time-Use** - `isUsed` Flag - **Max-Uses Tracking** - Optional - **IP-Restriktion** - Optional via `createdByIp` ### Action-Ausführung - **Payload-Validierung** - `validatePayload()` - **Permission-Checks** - `getRequiredPermissions()` - **Context-Validierung** - Request-Daten prüfen - **Error-Handling** - Graceful Degradation ## Cleanup & Wartung ### Automatisches Cleanup ```php // Cleanup-Command erstellen final readonly class CleanupExpiredMagicLinksCommand implements ConsoleCommand { public function __construct( private MagicLinkService $magiclinkService ) {} public function execute(ConsoleInput $input): int { $deleted = $this->magiclinkService->cleanupExpired(); echo "Deleted {$deleted} expired magiclinks\n"; return ExitCode::SUCCESS; } } ``` ### Scheduled Cleanup via Scheduler ```php // In Scheduler registrieren $scheduler->schedule( 'cleanup-magiclinks', CronSchedule::fromExpression('0 2 * * *'), // Täglich 02:00 fn() => $this->magiclinkService->cleanupExpired() ); ``` ## Testing ### Unit Tests ```php describe('MagicLinkService', function () { it('generates valid tokens', function () { $service = new InMemoryMagicLinkService(); $token = $service->generate( action: new TokenAction('test'), payload: ['user_id' => 1] ); expect($token->value)->toHaveLength(32); expect($service->exists($token))->toBeTrue(); }); it('validates tokens correctly', function () { $service = new InMemorySmartLinkService(); $token = $service->generate( action: new TokenAction('test'), payload: ['data' => 'test'], config: new TokenConfig(ttlSeconds: 3600) ); $data = $service->validate($token); expect($data)->not->toBeNull(); expect($data->payload['data'])->toBe('test'); expect($data->isValid())->toBeTrue(); }); it('marks one-time tokens as used', function () { $service = new InMemorySmartLinkService(); $token = $service->generate( action: new TokenAction('test'), payload: [], config: new TokenConfig(oneTimeUse: true) ); $service->markAsUsed($token); $data = $service->validate($token); expect($data->isValid())->toBeFalse(); }); }); ``` ### Integration Tests ```php describe('MagicLink Controller', function () { it('executes valid magiclink', function () { // Token generieren $command = new GenerateMagicLinkCommand( action: new TokenAction('email_verification'), payload: ['user_id' => 1, 'email' => 'test@example.com'], baseUrl: 'https://test.local' ); $url = $this->commandBus->dispatch($command); // Request simulieren $response = $this->get($url); expect($response->status)->toBe(Status::OK); }); }); ``` ## Best Practices ### 1. Token-Konfiguration - **Email-Verifizierung**: 24h TTL, One-Time-Use - **Passwort-Reset**: 1h TTL, One-Time-Use, IP-Restriction - **Dokumentenzugriff**: 1h TTL, Max-Uses basierend auf Bedarf - **Einladungen**: 7d TTL, One-Time-Use ### 2. Payload-Design - **Minimale Daten**: Nur notwendige IDs, keine sensiblen Daten - **Validierung**: Immer `validatePayload()` implementieren - **Versionierung**: Bei Schema-Änderungen Versionsnummer in Payload ### 3. Action-Implementation - **Idempotenz**: Actions sollten mehrfach ausführbar sein - **Atomarität**: Entweder vollständig erfolgreich oder vollständig fehlgeschlagen - **Logging**: Wichtige Actions loggen für Audit-Trail ### 4. Error-Handling - **Graceful Degradation**: Sinnvolle Fehlermeldungen - **User-Feedback**: Klare Kommunikation bei Problemen - **Security**: Keine internen Details in Fehlermeldungen ### 5. Monitoring - **Token-Usage**: Tracking von Token-Generierung und -Verwendung - **Action-Success-Rate**: Erfolgsrate verschiedener Actions - **Expired-Tokens**: Anzahl abgelaufener Tokens monitoren - **Cleanup-Effizienz**: Performance des Cleanup-Prozesses ## Troubleshooting ### Problem: Token wird nicht validiert **Ursachen**: - Token abgelaufen - Token bereits verwendet (One-Time-Use) - Token wurde widerrufen - Cache-Inkonsistenz **Lösung**: ```php $data = $service->validate($token); if (!$data) { // Token existiert nicht } elseif ($data->isExpired()) { // Token abgelaufen } elseif ($data->isUsed) { // Token bereits verwendet } ``` ### Problem: Action schlägt fehl **Ursachen**: - Payload-Validierung fehlgeschlagen - Business-Logic-Fehler - Fehlende Permissions **Lösung**: ```php if (!$action->validatePayload($data->payload)) { return new ActionResult( success: false, message: 'Ungültige Token-Daten', errors: ['Invalid payload structure'] ); } ``` ### Problem: Performance bei vielen Tokens **Lösungen**: - **Regelmäßiges Cleanup**: Expired Tokens entfernen - **Index-Optimierung**: Bei Database-backed Service - **Cache-Strategie**: Richtige Cache-TTL wählen - **Batch-Operations**: Cleanup in Batches ## Framework-Integration ### Mit Event System ```php // Event bei Token-Generierung $this->eventDispatcher->dispatch( new MagicLinkGeneratedEvent($token, $action) ); // Event bei Action-Ausführung $this->eventDispatcher->dispatch( new MagicLinkExecutedEvent($token, $result) ); ``` ### Mit Queue System ```php // Asynchrone Action-Ausführung final readonly class MagicLinkExecutionJob { public function handle(): void { $command = new ExecuteMagicLinkCommand($this->token); $this->commandBus->dispatch($command); } } ``` ### Mit Scheduler ```php // Regelmäßiges Cleanup $scheduler->schedule( 'magiclink-cleanup', IntervalSchedule::every(Duration::fromHours(6)), fn() => $this->magiclinkService->cleanupExpired() ); ``` ## Erweiterungen ### Multi-Factor MagicLinks ```php final readonly class MfaRequiredAction implements MagicLinkAction { public function execute(MagicLinkData $data, array $context = []): ActionResult { // Zusätzliche MFA-Verifikation anfordern if (!isset($context['mfa_code'])) { return new ActionResult( success: false, message: 'MFA-Code erforderlich', data: ['requires_mfa' => true] ); } // MFA validieren und Action ausführen } } ``` ### Rate-Limited Actions ```php final readonly class RateLimitedAction implements MagicLinkAction { public function execute(MagicLinkData $data, array $context = []): ActionResult { $ipAddress = $context['ip_address']; if ($this->rateLimiter->tooManyAttempts($ipAddress, 5, 3600)) { return new ActionResult( success: false, message: 'Zu viele Versuche' ); } // Action ausführen } } ``` ## Zusammenfassung Das MagicLinks System bietet: - ✅ **Sichere Token-Generierung** mit konfigurierbarer TTL - ✅ **Flexible Action-System** für verschiedene Use-Cases - ✅ **Command/Handler Pattern** für saubere Architektur - ✅ **One-Time-Use & Max-Uses** Support - ✅ **IP-Restriction** und Security-Features - ✅ **Automatisches Cleanup** abgelaufener Tokens - ✅ **Framework-Integration** mit Events, Queue, Scheduler - ✅ **Testbare Architektur** mit In-Memory Implementation Das System folgt konsequent Framework-Patterns: - **Value Objects** für Token, Action, Config - **Readonly Classes** für Unveränderlichkeit - **Interface-Driven** für Flexibilität - **Command/Handler** für Business Logic - **Dependency Injection** für Testbarkeit