Files
michaelschiemer/docs/claude/magiclinks-system.md
Michael Schiemer 5050c7d73a docs: consolidate documentation into organized structure
- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
2025-10-05 11:05:04 +02:00

21 KiB

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:

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:

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:

final readonly class TokenAction
{
    public function __construct(
        public string $value  // z.B. 'email_verification', 'password_reset'
    ) {}
}

TokenConfig - Token-Konfiguration:

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:

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:

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:

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:

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:

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:

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:

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:

final readonly class GenericDataAccessAction implements MagicLinkAction
{
    public function getName(): string
    {
        return 'data_access';
    }
}

Command/Handler Pattern

GenerateMagicLinkCommand:

final readonly class GenerateMagicLinkCommand
{
    public function __construct(
        public TokenAction $action,
        public array $payload,
        public string $baseUrl,
        public ?TokenConfig $config = null
    ) {}
}

GenerateMagicLinkHandler:

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:

final readonly class ExecuteMagicLinkCommand
{
    public function __construct(
        public MagicLinkToken $token,
        public array $context = []
    ) {}
}

ExecuteMagicLinkHandler:

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)

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

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

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

// 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

// 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

// 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

// In Scheduler registrieren
$scheduler->schedule(
    'cleanup-magiclinks',
    CronSchedule::fromExpression('0 2 * * *'),  // Täglich 02:00
    fn() => $this->magiclinkService->cleanupExpired()
);

Testing

Unit Tests

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

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:

$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:

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

// 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

// Asynchrone Action-Ausführung
final readonly class MagicLinkExecutionJob
{
    public function handle(): void
    {
        $command = new ExecuteMagicLinkCommand($this->token);
        $this->commandBus->dispatch($command);
    }
}

Mit Scheduler

// Regelmäßiges Cleanup
$scheduler->schedule(
    'magiclink-cleanup',
    IntervalSchedule::every(Duration::fromHours(6)),
    fn() => $this->magiclinkService->cleanupExpired()
);

Erweiterungen

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

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