- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
26 KiB
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);
}
}