Files
michaelschiemer/docs/components/auth/examples.md
Michael Schiemer 55a330b223 Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
2025-08-11 20:13:26 +02:00

26 KiB

Auth Module Examples

Praktische Implementierungsbeispiele für das Auth Module des Custom PHP Frameworks.

Basic Authentication Flow

User Registration

use App\Framework\Auth\PasswordHasher;
use App\Framework\Auth\PasswordValidationResult;

final readonly class UserRegistrationService
{
    public function __construct(
        private PasswordHasher $passwordHasher,
        private UserRepository $userRepository
    ) {}
    
    public function register(
        string $email,
        #[SensitiveParameter] string $password,
        string $username = null
    ): RegistrationResult {
        // Validate password strength
        $validation = $this->passwordHasher->validatePasswordStrength($password);
        if (!$validation->isValid) {
            return RegistrationResult::passwordValidationFailed($validation);
        }
        
        // Check if user exists
        if ($this->userRepository->findByEmail($email)) {
            return RegistrationResult::failed('User with this email already exists');
        }
        
        // Hash password
        $hashedPassword = $this->passwordHasher->hash($password);
        
        // Create user
        $user = new User(
            id: Uuid::generate(),
            email: new Email($email),
            username: $username,
            hashedPassword: $hashedPassword,
            createdAt: new \DateTimeImmutable()
        );
        
        $this->userRepository->save($user);
        
        return RegistrationResult::success($user);
    }
}

Login Implementation

use App\Framework\Auth\AuthenticationService;
use App\Framework\Http\IpAddress;
use App\Framework\Http\Session\SessionManager;

#[Route(path: '/login', method: Method::POST)]
final readonly class LoginController
{
    public function __construct(
        private AuthenticationService $authService,
        private SessionManager $sessionManager
    ) {}
    
    public function login(LoginRequest $request): JsonResult
    {
        $ipAddress = IpAddress::fromRequest();
        
        $result = $this->authService->authenticate(
            identifier: $request->email,
            password: $request->password,
            ipAddress: $ipAddress,
            remember: $request->remember ?? false
        );
        
        if ($result->isSuccess()) {
            $user = $result->getUser();
            $session = $result->getSession();
            
            // Store session in session manager
            $this->sessionManager->start($session->getId());
            $this->sessionManager->set('user_id', $user->getId());
            $this->sessionManager->set('authenticated_at', time());
            
            $responseData = [
                'success' => true,
                'user' => [
                    'id' => $user->getId(),
                    'email' => (string) $user->getEmail(),
                    'username' => $user->getUsername()
                ],
                'session' => [
                    'id' => $session->getId()->toString(),
                    'expires_at' => $session->getExpiresAt()->format(\DateTimeInterface::ATOM)
                ]
            ];
            
            // Add remember token to response if requested
            if ($rememberToken = $result->getRememberToken()) {
                $responseData['remember_token'] = $rememberToken->getPlainTextValue();
                
                // Set secure HTTP-only cookie
                setcookie(
                    'remember_token',
                    $rememberToken->getPlainTextValue(),
                    [
                        'expires' => $rememberToken->getExpiresAt()->getTimestamp(),
                        'path' => '/',
                        'secure' => true,
                        'httponly' => true,
                        'samesite' => 'Strict'
                    ]
                );
            }
            
            return new JsonResult($responseData);
        }
        
        // Handle different failure types
        return match (true) {
            $result->isRateLimited() => new JsonResult([
                'success' => false,
                'error' => 'Too many attempts',
                'retry_after' => $result->getRetryAfter()
            ], 429),
            
            $result->isAccountLocked() => new JsonResult([
                'success' => false,
                'error' => 'Account temporarily locked',
                'locked_until' => $result->getLockoutExpiresAt()?->format(\DateTimeInterface::ATOM)
            ], 423),
            
            default => new JsonResult([
                'success' => false,
                'error' => 'Invalid credentials'
            ], 401)
        };
    }
}

Auto-Login with Remember Token

#[Route(path: '/auth/check', method: Method::GET)]
final readonly class AuthCheckController
{
    public function __construct(
        private AuthenticationService $authService,
        private SessionManager $sessionManager
    ) {}
    
    public function check(HttpRequest $request): JsonResult
    {
        $sessionId = $this->sessionManager->getCurrentSessionId();
        
        // Try session authentication first
        if ($sessionId) {
            $result = $this->authService->authenticateWithSession(
                $sessionId,
                IpAddress::fromRequest()
            );
            
            if ($result->isSuccess()) {
                return new JsonResult([
                    'authenticated' => true,
                    'user' => $this->formatUser($result->getUser()),
                    'session' => $this->formatSession($result->getSession())
                ]);
            }
        }
        
        // Try remember token authentication
        $rememberToken = $request->getCookie('remember_token');
        if ($rememberToken) {
            $result = $this->authService->authenticateWithRememberToken(
                $rememberToken,
                IpAddress::fromRequest()
            );
            
            if ($result->isSuccess()) {
                // Start new session
                $session = $result->getSession();
                $this->sessionManager->start($session->getId());
                $this->sessionManager->set('user_id', $result->getUser()->getId());
                
                // Update remember token cookie
                $newRememberToken = $result->getRememberToken();
                if ($newRememberToken) {
                    setcookie(
                        'remember_token',
                        $newRememberToken->getPlainTextValue(),
                        [
                            'expires' => $newRememberToken->getExpiresAt()->getTimestamp(),
                            'path' => '/',
                            'secure' => true,
                            'httponly' => true,
                            'samesite' => 'Strict'
                        ]
                    );
                }
                
                return new JsonResult([
                    'authenticated' => true,
                    'user' => $this->formatUser($result->getUser()),
                    'session' => $this->formatSession($session)
                ]);
            }
        }
        
        return new JsonResult(['authenticated' => false]);
    }
    
    private function formatUser(User $user): array
    {
        return [
            'id' => $user->getId(),
            'email' => (string) $user->getEmail(),
            'username' => $user->getUsername(),
            'last_login' => $user->getLastLoginAt()?->format(\DateTimeInterface::ATOM)
        ];
    }
    
    private function formatSession(AuthenticationSession $session): array
    {
        return [
            'id' => $session->getId()->toString(),
            'created_at' => $session->getCreatedAt()->format(\DateTimeInterface::ATOM),
            'expires_at' => $session->getExpiresAt()->format(\DateTimeInterface::ATOM),
            'last_activity' => $session->getLastActivity()->format(\DateTimeInterface::ATOM)
        ];
    }
}

Password Management

Password Change

#[Route(path: '/account/change-password', method: Method::POST)]
final readonly class ChangePasswordController
{
    public function __construct(
        private AuthenticationService $authService,
        private SessionManager $sessionManager
    ) {}
    
    public function changePassword(ChangePasswordRequest $request): JsonResult
    {
        $userId = $this->sessionManager->get('user_id');
        if (!$userId) {
            return new JsonResult(['error' => 'Not authenticated'], 401);
        }
        
        $result = $this->authService->changePassword(
            userId: $userId,
            currentPassword: $request->currentPassword,
            newPassword: $request->newPassword
        );
        
        if ($result->isSuccess()) {
            return new JsonResult([
                'success' => true,
                'message' => 'Password changed successfully'
            ]);
        }
        
        if ($result->hasValidationErrors()) {
            return new JsonResult([
                'success' => false,
                'validation_errors' => $result->getValidation()->toArray()
            ], 400);
        }
        
        return new JsonResult([
            'success' => false,
            'error' => $result->getErrorMessage()
        ], 400);
    }
}

Password Reset Flow

final readonly class PasswordResetService
{
    public function __construct(
        private PasswordHasher $passwordHasher,
        private UserRepository $userRepository,
        private TokenGenerator $tokenGenerator,
        private EmailService $emailService
    ) {}
    
    public function initiateReset(string $email): PasswordResetResult
    {
        $user = $this->userRepository->findByEmail($email);
        if (!$user) {
            // Return success even if user not found (security)
            return PasswordResetResult::success();
        }
        
        // Generate secure reset token
        $token = $this->tokenGenerator->generateSecureToken(32);
        $tokenHash = Hash::sha256($token);
        $expiresAt = new \DateTimeImmutable('+1 hour');
        
        $resetToken = new PasswordResetToken(
            hash: $tokenHash,
            userId: $user->getId(),
            createdAt: new \DateTimeImmutable(),
            expiresAt: $expiresAt
        );
        
        $this->userRepository->storePasswordResetToken($resetToken);
        
        // Send reset email
        $this->emailService->sendPasswordResetEmail($user->getEmail(), $token);
        
        return PasswordResetResult::success();
    }
    
    public function resetPassword(
        string $token,
        #[SensitiveParameter] string $newPassword
    ): PasswordResetResult {
        $tokenHash = Hash::sha256($token);
        $resetToken = $this->userRepository->findPasswordResetToken($tokenHash);
        
        if (!$resetToken || $resetToken->isExpired()) {
            return PasswordResetResult::failed('Invalid or expired reset token');
        }
        
        $user = $this->userRepository->findById($resetToken->getUserId());
        if (!$user) {
            return PasswordResetResult::failed('User not found');
        }
        
        // Validate new password
        $validation = $this->passwordHasher->validatePasswordStrength($newPassword);
        if (!$validation->isValid) {
            return PasswordResetResult::validationFailed($validation);
        }
        
        // Hash new password
        $hashedPassword = $this->passwordHasher->hash($newPassword);
        
        // Update password and cleanup
        $this->userRepository->updateUserPassword($user->getId(), $hashedPassword);
        $this->userRepository->deletePasswordResetToken($tokenHash);
        $this->userRepository->deleteAllUserSessions($user->getId());
        
        return PasswordResetResult::success();
    }
}

Advanced Security Features

Multi-Factor Authentication Setup

final readonly class MfaService
{
    public function __construct(
        private PasswordHasher $passwordHasher,
        private TotpService $totpService,
        private UserRepository $userRepository
    ) {}
    
    public function setupTotp(string $userId): MfaSetupResult
    {
        $user = $this->userRepository->findById($userId);
        if (!$user) {
            return MfaSetupResult::failed('User not found');
        }
        
        // Generate TOTP secret
        $secret = $this->totpService->generateSecret();
        
        // Create QR code data
        $qrCodeUri = $this->totpService->getQrCodeUri(
            secret: $secret,
            accountName: (string) $user->getEmail(),
            issuer: 'Your App Name'
        );
        
        // Store temporary secret (not activated until verified)
        $tempSecret = new TempTotpSecret(
            userId: $userId,
            secret: $secret,
            createdAt: new \DateTimeImmutable(),
            expiresAt: new \DateTimeImmutable('+10 minutes')
        );
        
        $this->userRepository->storeTempTotpSecret($tempSecret);
        
        return MfaSetupResult::success($secret, $qrCodeUri);
    }
    
    public function verifyAndActivateTotp(
        string $userId,
        string $totpCode
    ): MfaActivationResult {
        $tempSecret = $this->userRepository->findTempTotpSecret($userId);
        if (!$tempSecret || $tempSecret->isExpired()) {
            return MfaActivationResult::failed('Setup expired, please restart');
        }
        
        // Verify TOTP code
        if (!$this->totpService->verify($totpCode, $tempSecret->getSecret())) {
            return MfaActivationResult::failed('Invalid TOTP code');
        }
        
        // Activate TOTP for user
        $this->userRepository->activateTotpForUser($userId, $tempSecret->getSecret());
        $this->userRepository->deleteTempTotpSecret($userId);
        
        // Generate backup codes
        $backupCodes = $this->generateBackupCodes($userId);
        
        return MfaActivationResult::success($backupCodes);
    }
    
    private function generateBackupCodes(string $userId): array
    {
        $codes = [];
        for ($i = 0; $i < 8; $i++) {
            $code = $this->generateReadableCode();
            $codes[] = $code;
            
            $backupCode = new MfaBackupCode(
                userId: $userId,
                code: Hash::sha256($code),
                createdAt: new \DateTimeImmutable(),
                usedAt: null
            );
            
            $this->userRepository->storeBackupCode($backupCode);
        }
        
        return $codes;
    }
    
    private function generateReadableCode(): string
    {
        // Generate 8-digit backup code
        return sprintf('%04d-%04d', random_int(1000, 9999), random_int(1000, 9999));
    }
}

Session Management with Device Tracking

final readonly class DeviceTrackingService
{
    public function __construct(
        private AuthenticationService $authService,
        private SessionRepository $sessionRepository
    ) {}
    
    public function authenticateWithDeviceTracking(
        string $identifier,
        #[SensitiveParameter] string $password,
        HttpRequest $request
    ): AuthenticationResult {
        $ipAddress = IpAddress::fromRequest();
        $userAgent = $request->server->getUserAgent();
        
        $result = $this->authService->authenticate($identifier, $password, $ipAddress);
        
        if ($result->isSuccess()) {
            $session = $result->getSession();
            $user = $result->getUser();
            
            // Create device fingerprint
            $deviceInfo = new DeviceInfo(
                userAgent: $userAgent,
                ipAddress: $ipAddress,
                screenResolution: $request->headers->get('X-Screen-Resolution'),
                timezone: $request->headers->get('X-Timezone'),
                language: $request->headers->get('Accept-Language')
            );
            
            // Check if this is a new device
            $isNewDevice = !$this->sessionRepository->hasRecentSessionForDevice(
                userId: $user->getId(),
                deviceFingerprint: $deviceInfo->getFingerprint(),
                withinDays: 30
            );
            
            if ($isNewDevice) {
                // Send security notification
                $this->sendNewDeviceNotification($user, $deviceInfo, $ipAddress);
            }
            
            // Update session with device info
            $this->sessionRepository->updateSessionDeviceInfo($session->getId(), $deviceInfo);
        }
        
        return $result;
    }
    
    public function listActiveSessions(string $userId): array
    {
        $sessions = $this->sessionRepository->getActiveSessionsForUser($userId);
        
        return array_map(function (AuthenticationSession $session) {
            $deviceInfo = $this->sessionRepository->getSessionDeviceInfo($session->getId());
            
            return [
                'id' => $session->getId()->toString(),
                'created_at' => $session->getCreatedAt()->format(\DateTimeInterface::ATOM),
                'last_activity' => $session->getLastActivity()->format(\DateTimeInterface::ATOM),
                'ip_address' => (string) $session->getIpAddress(),
                'device_info' => $deviceInfo?->toArray(),
                'is_current' => $this->isCurrentSession($session->getId())
            ];
        }, $sessions);
    }
    
    public function revokeSession(string $userId, SessionId $sessionId): bool
    {
        // Verify session belongs to user
        $session = $this->sessionRepository->findSessionById($sessionId);
        if (!$session || $session->getUserId() !== $userId) {
            return false;
        }
        
        return $this->authService->logout($sessionId);
    }
    
    private function sendNewDeviceNotification(
        User $user,
        DeviceInfo $deviceInfo,
        IpAddress $ipAddress
    ): void {
        // Implementation would send email/SMS notification
        // about login from new device
    }
    
    private function isCurrentSession(SessionId $sessionId): bool
    {
        // Check if this is the current session
        return session_id() === $sessionId->toString();
    }
}

Rate Limiting & Security

Custom Rate Limiting Implementation

final readonly class CustomRateLimitService implements RateLimitService
{
    public function __construct(
        private Cache $cache,
        private int $maxAttempts = 5,
        private int $windowSeconds = 300,
        private int $lockoutDuration = 900
    ) {}
    
    public function isRateLimited(IpAddress $ipAddress, string $action): bool
    {
        $key = $this->getRateLimitKey($ipAddress, $action);
        $attempts = $this->cache->get($key, 0);
        
        return $attempts >= $this->maxAttempts;
    }
    
    public function recordAttempt(IpAddress $ipAddress, string $action): void
    {
        $key = $this->getRateLimitKey($ipAddress, $action);
        $attempts = $this->cache->get($key, 0);
        
        $this->cache->set($key, $attempts + 1, $this->windowSeconds);
        
        if ($attempts + 1 >= $this->maxAttempts) {
            $lockoutKey = $this->getLockoutKey($ipAddress, $action);
            $this->cache->set($lockoutKey, true, $this->lockoutDuration);
        }
    }
    
    public function clearAttempts(IpAddress $ipAddress, string $action): void
    {
        $key = $this->getRateLimitKey($ipAddress, $action);
        $lockoutKey = $this->getLockoutKey($ipAddress, $action);
        
        $this->cache->forget($key);
        $this->cache->forget($lockoutKey);
    }
    
    public function getRetryAfter(IpAddress $ipAddress, string $action): int
    {
        $lockoutKey = $this->getLockoutKey($ipAddress, $action);
        $lockoutExpiry = $this->cache->get($lockoutKey);
        
        if (!$lockoutExpiry) {
            return 0;
        }
        
        return max(0, $lockoutExpiry - time());
    }
    
    private function getRateLimitKey(IpAddress $ipAddress, string $action): string
    {
        return sprintf('rate_limit:%s:%s', $action, (string) $ipAddress);
    }
    
    private function getLockoutKey(IpAddress $ipAddress, string $action): string
    {
        return sprintf('lockout:%s:%s', $action, (string) $ipAddress);
    }
}

Testing Examples

Authentication Service Testing

use PHPUnit\Framework\TestCase;
use App\Framework\Auth\AuthenticationService;

final class AuthenticationServiceTest extends TestCase
{
    private AuthenticationService $authService;
    private PasswordHasher $passwordHasher;
    private MockAuthenticationRepository $repository;
    
    protected function setUp(): void
    {
        $this->passwordHasher = new PasswordHasher(
            kdf: new MockKeyDerivationFunction(),
            defaultAlgorithm: 'argon2id',
            defaultSecurityLevel: PasswordHasher::LEVEL_LOW // Fast for testing
        );
        
        $this->repository = new MockAuthenticationRepository();
        
        $this->authService = new AuthenticationService(
            passwordHasher: $this->passwordHasher,
            sessionIdGenerator: new MockSessionIdGenerator(),
            repository: $this->repository
        );
    }
    
    public function test_successful_authentication(): void
    {
        $password = 'SecurePassword123!';
        $hashedPassword = $this->passwordHasher->hash($password);
        
        $user = new User(
            id: 'user-1',
            email: new Email('user@example.com'),
            username: 'testuser',
            hashedPassword: $hashedPassword
        );
        
        $this->repository->addUser($user);
        
        $result = $this->authService->authenticate(
            identifier: 'user@example.com',
            password: $password,
            ipAddress: IpAddress::localhost()
        );
        
        $this->assertTrue($result->isSuccess());
        $this->assertEquals($user->getId(), $result->getUser()->getId());
        $this->assertInstanceOf(AuthenticationSession::class, $result->getSession());
    }
    
    public function test_failed_authentication_with_invalid_password(): void
    {
        $hashedPassword = $this->passwordHasher->hash('CorrectPassword123!');
        
        $user = new User(
            id: 'user-1',
            email: new Email('user@example.com'),
            username: 'testuser',
            hashedPassword: $hashedPassword
        );
        
        $this->repository->addUser($user);
        
        $result = $this->authService->authenticate(
            identifier: 'user@example.com',
            password: 'WrongPassword',
            ipAddress: IpAddress::localhost()
        );
        
        $this->assertFalse($result->isSuccess());
        $this->assertEquals('Invalid credentials', $result->getErrorMessage());
    }
    
    public function test_account_lockout_after_max_attempts(): void
    {
        $hashedPassword = $this->passwordHasher->hash('CorrectPassword123!');
        
        $user = new User(
            id: 'user-1',
            email: new Email('user@example.com'),
            username: 'testuser',
            hashedPassword: $hashedPassword
        );
        
        $this->repository->addUser($user);
        
        // Simulate failed attempts
        for ($i = 0; $i < AuthenticationService::MAX_LOGIN_ATTEMPTS; $i++) {
            $this->authService->authenticate(
                identifier: 'user@example.com',
                password: 'WrongPassword',
                ipAddress: IpAddress::localhost()
            );
        }
        
        // Next attempt should be locked
        $result = $this->authService->authenticate(
            identifier: 'user@example.com',
            password: 'CorrectPassword123!',
            ipAddress: IpAddress::localhost()
        );
        
        $this->assertTrue($result->isAccountLocked());
        $this->assertInstanceOf(\DateTimeImmutable::class, $result->getLockoutExpiresAt());
    }
}

Password Hashing Testing

final class PasswordHasherTest extends TestCase
{
    private PasswordHasher $passwordHasher;
    
    protected function setUp(): void
    {
        $this->passwordHasher = new PasswordHasher(
            kdf: new MockKeyDerivationFunction(),
            defaultAlgorithm: 'argon2id',
            defaultSecurityLevel: PasswordHasher::LEVEL_LOW
        );
    }
    
    public function test_password_hashing_and_verification(): void
    {
        $password = 'TestPassword123!';
        
        $hashedPassword = $this->passwordHasher->hash($password);
        
        $this->assertInstanceOf(HashedPassword::class, $hashedPassword);
        $this->assertEquals('argon2id', $hashedPassword->getAlgorithm());
        $this->assertTrue($this->passwordHasher->verify($password, $hashedPassword));
        $this->assertFalse($this->passwordHasher->verify('WrongPassword', $hashedPassword));
    }
    
    public function test_password_strength_validation(): void
    {
        $weakPassword = 'weak';
        $strongPassword = 'StrongPassword123!@#';
        
        $weakValidation = $this->passwordHasher->validatePasswordStrength($weakPassword);
        $strongValidation = $this->passwordHasher->validatePasswordStrength($strongPassword);
        
        $this->assertFalse($weakValidation->isValid);
        $this->assertNotEmpty($weakValidation->errors);
        $this->assertEquals(PasswordStrength::WEAK, $weakValidation->strength);
        
        $this->assertTrue($strongValidation->isValid);
        $this->assertEmpty($strongValidation->errors);
        $this->assertContains($strongValidation->strength, [
            PasswordStrength::STRONG,
            PasswordStrength::VERY_STRONG
        ]);
    }
    
    public function test_secure_password_generation(): void
    {
        $password = $this->passwordHasher->generateSecurePassword(16);
        
        $this->assertEquals(16, strlen($password));
        
        $validation = $this->passwordHasher->validatePasswordStrength($password);
        $this->assertTrue($validation->isValid);
        $this->assertGreaterThanOrEqual(70, $validation->strengthScore);
    }
}