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

808 lines
21 KiB
Markdown

# 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