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
This commit is contained in:
904
docs/components/auth/security.md
Normal file
904
docs/components/auth/security.md
Normal file
@@ -0,0 +1,904 @@
|
||||
# Auth Module Security Guidelines
|
||||
|
||||
**Sicherheitsrichtlinien und Best Practices** für das Auth Module des Custom PHP Frameworks.
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### Defense in Depth Strategy
|
||||
|
||||
Das Auth Module implementiert mehrschichtige Sicherheitsmaßnahmen:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Application Layer │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ • Rate Limiting & Account Lockout │
|
||||
│ • Session Security & IP Tracking │
|
||||
│ • Multi-Factor Authentication │
|
||||
│ • Password Strength Validation │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Framework Layer │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ • Secure Password Hashing (Argon2ID) │
|
||||
│ • Timing-Safe Comparisons │
|
||||
│ • Cryptographically Secure Random Generation │
|
||||
│ • Hash Value Object with Algorithm Validation │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Infrastructure Layer │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ • HTTPS Enforcement │
|
||||
│ • Secure Headers (HSTS, CSP) │
|
||||
│ • Database Security & Prepared Statements │
|
||||
│ • Logging & Monitoring │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Password Security
|
||||
|
||||
### Secure Password Hashing
|
||||
|
||||
```php
|
||||
// ✅ Recommended: Argon2ID with high memory cost
|
||||
$passwordHasher = new PasswordHasher(
|
||||
kdf: $keyDerivationFunction,
|
||||
defaultAlgorithm: 'argon2id',
|
||||
defaultSecurityLevel: PasswordHasher::LEVEL_HIGH // Production
|
||||
);
|
||||
|
||||
// Security parameters for different levels:
|
||||
// LEVEL_HIGH: 128 MB memory, 6 iterations, 4 threads
|
||||
// LEVEL_STANDARD: 64 MB memory, 4 iterations, 3 threads
|
||||
// LEVEL_LOW: 32 MB memory, 2 iterations, 2 threads
|
||||
|
||||
// ❌ Avoid: Weak algorithms or low security levels in production
|
||||
$weakHasher = new PasswordHasher(
|
||||
kdf: $kdf,
|
||||
defaultAlgorithm: 'pbkdf2-sha256', // Less secure than Argon2ID
|
||||
defaultSecurityLevel: PasswordHasher::LEVEL_LOW
|
||||
);
|
||||
```
|
||||
|
||||
### Password Policy Enforcement
|
||||
|
||||
```php
|
||||
final readonly class PasswordPolicy
|
||||
{
|
||||
public const int MIN_LENGTH = 12; // Increased from 8
|
||||
public const int MIN_COMPLEXITY_TYPES = 3; // Upper, lower, numbers, special
|
||||
public const int MIN_STRENGTH_SCORE = 70; // High security threshold
|
||||
|
||||
public function validatePassword(string $password): PasswordPolicyResult
|
||||
{
|
||||
$violations = [];
|
||||
|
||||
// Length requirement
|
||||
if (mb_strlen($password) < self::MIN_LENGTH) {
|
||||
$violations[] = "Password must be at least " . self::MIN_LENGTH . " characters";
|
||||
}
|
||||
|
||||
// Complexity requirements
|
||||
$complexityScore = $this->calculateComplexity($password);
|
||||
if ($complexityScore < self::MIN_COMPLEXITY_TYPES) {
|
||||
$violations[] = "Password must include at least " . self::MIN_COMPLEXITY_TYPES . " character types";
|
||||
}
|
||||
|
||||
// Entropy check
|
||||
if ($this->calculateEntropy($password) < 50) {
|
||||
$violations[] = "Password lacks sufficient entropy";
|
||||
}
|
||||
|
||||
// Dictionary check
|
||||
if ($this->isCommonPassword($password)) {
|
||||
$violations[] = "Password is too common";
|
||||
}
|
||||
|
||||
// Personal information check
|
||||
if ($this->containsPersonalInfo($password)) {
|
||||
$violations[] = "Password should not contain personal information";
|
||||
}
|
||||
|
||||
return new PasswordPolicyResult(
|
||||
isValid: empty($violations),
|
||||
violations: $violations,
|
||||
strengthScore: $this->calculateStrengthScore($password)
|
||||
);
|
||||
}
|
||||
|
||||
private function calculateEntropy(string $password): float
|
||||
{
|
||||
$length = strlen($password);
|
||||
$charsetSize = 0;
|
||||
|
||||
if (preg_match('/[a-z]/', $password)) $charsetSize += 26;
|
||||
if (preg_match('/[A-Z]/', $password)) $charsetSize += 26;
|
||||
if (preg_match('/[0-9]/', $password)) $charsetSize += 10;
|
||||
if (preg_match('/[^A-Za-z0-9]/', $password)) $charsetSize += 32;
|
||||
|
||||
return $length * log($charsetSize, 2);
|
||||
}
|
||||
|
||||
private function isCommonPassword(string $password): bool
|
||||
{
|
||||
// Check against common password lists (e.g., Have I Been Pwned)
|
||||
$commonPasswords = [
|
||||
'password', '123456', 'password123', 'admin', 'qwerty',
|
||||
'letmein', 'welcome', 'monkey', 'dragon', '123456789'
|
||||
];
|
||||
|
||||
return in_array(strtolower($password), $commonPasswords, true);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Automatic Password Rehashing
|
||||
|
||||
```php
|
||||
// ✅ Implement automatic rehashing on authentication
|
||||
public function authenticate(string $identifier, string $password): AuthenticationResult
|
||||
{
|
||||
$user = $this->repository->findByIdentifier($identifier);
|
||||
if (!$user || !$this->passwordHasher->verify($password, $user->getHashedPassword())) {
|
||||
return AuthenticationResult::failed('Invalid credentials');
|
||||
}
|
||||
|
||||
// Critical: Check for rehashing need
|
||||
if ($this->passwordHasher->needsRehash($user->getHashedPassword())) {
|
||||
$newHash = $this->passwordHasher->hash($password);
|
||||
$this->repository->updateUserPassword($user->getId(), $newHash);
|
||||
|
||||
$this->logger->info('Password automatically rehashed', [
|
||||
'user_id' => $user->getId(),
|
||||
'old_algorithm' => $user->getHashedPassword()->getAlgorithm(),
|
||||
'new_algorithm' => $newHash->getAlgorithm()
|
||||
]);
|
||||
}
|
||||
|
||||
return AuthenticationResult::success($user);
|
||||
}
|
||||
```
|
||||
|
||||
## Session Security
|
||||
|
||||
### Secure Session Management
|
||||
|
||||
```php
|
||||
final readonly class SecureSessionManager
|
||||
{
|
||||
public function __construct(
|
||||
private AuthenticationService $authService,
|
||||
private SessionSecurity $sessionSecurity
|
||||
) {}
|
||||
|
||||
public function createSecureSession(
|
||||
User $user,
|
||||
IpAddress $ipAddress,
|
||||
UserAgent $userAgent
|
||||
): AuthenticationSession {
|
||||
// Generate cryptographically secure session ID
|
||||
$sessionId = $this->generateSecureSessionId();
|
||||
|
||||
// Create session with security attributes
|
||||
$session = new AuthenticationSession(
|
||||
id: $sessionId,
|
||||
userId: $user->getId(),
|
||||
ipAddress: $ipAddress,
|
||||
userAgent: $userAgent,
|
||||
createdAt: new \DateTimeImmutable(),
|
||||
expiresAt: new \DateTimeImmutable('+1 hour'),
|
||||
lastActivity: new \DateTimeImmutable(),
|
||||
securityAttributes: new SessionSecurityAttributes(
|
||||
isSecure: true,
|
||||
isHttpOnly: true,
|
||||
sameSite: 'Strict',
|
||||
fingerprint: $this->generateFingerprint($ipAddress, $userAgent)
|
||||
)
|
||||
);
|
||||
|
||||
// Set secure session cookie
|
||||
$this->setSecureSessionCookie($sessionId, $session->getExpiresAt());
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
private function generateSecureSessionId(): SessionId
|
||||
{
|
||||
// Use framework's SessionIdGenerator for consistency
|
||||
return $this->sessionIdGenerator->generate();
|
||||
}
|
||||
|
||||
private function setSecureSessionCookie(SessionId $sessionId, \DateTimeImmutable $expiresAt): void
|
||||
{
|
||||
setcookie('session_id', $sessionId->toString(), [
|
||||
'expires' => $expiresAt->getTimestamp(),
|
||||
'path' => '/',
|
||||
'domain' => '', // Let browser determine
|
||||
'secure' => true, // HTTPS only
|
||||
'httponly' => true, // No JavaScript access
|
||||
'samesite' => 'Strict' // CSRF protection
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateFingerprint(IpAddress $ipAddress, UserAgent $userAgent): string
|
||||
{
|
||||
return Hash::sha256(
|
||||
$ipAddress->toString() . '|' .
|
||||
$userAgent->toString() . '|' .
|
||||
$_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''
|
||||
)->toString();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Session Hijacking Protection
|
||||
|
||||
```php
|
||||
final readonly class SessionHijackingProtection
|
||||
{
|
||||
public function validateSession(
|
||||
SessionId $sessionId,
|
||||
IpAddress $currentIp,
|
||||
UserAgent $currentUserAgent
|
||||
): SessionValidationResult {
|
||||
$session = $this->repository->findSessionById($sessionId);
|
||||
if (!$session) {
|
||||
return SessionValidationResult::invalid('Session not found');
|
||||
}
|
||||
|
||||
// Check session expiration
|
||||
if ($session->isExpired()) {
|
||||
$this->repository->deleteSession($sessionId);
|
||||
return SessionValidationResult::expired();
|
||||
}
|
||||
|
||||
// IP address consistency check (configurable)
|
||||
if ($this->config->checkIpConsistency) {
|
||||
if (!$session->getIpAddress()->equals($currentIp)) {
|
||||
$this->logSecurityEvent('session_ip_mismatch', [
|
||||
'session_id' => $sessionId->toString(),
|
||||
'original_ip' => (string) $session->getIpAddress(),
|
||||
'current_ip' => (string) $currentIp
|
||||
]);
|
||||
|
||||
// Suspicious activity - invalidate session
|
||||
$this->repository->deleteSession($sessionId);
|
||||
return SessionValidationResult::suspicious('IP address mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
// User Agent consistency (less strict)
|
||||
if (!$this->isUserAgentConsistent($session->getUserAgent(), $currentUserAgent)) {
|
||||
$this->logSecurityEvent('session_user_agent_change', [
|
||||
'session_id' => $sessionId->toString(),
|
||||
'original_ua' => (string) $session->getUserAgent(),
|
||||
'current_ua' => (string) $currentUserAgent
|
||||
]);
|
||||
}
|
||||
|
||||
// Update session activity
|
||||
$this->repository->updateSessionActivity($sessionId, $currentIp);
|
||||
|
||||
return SessionValidationResult::valid($session);
|
||||
}
|
||||
|
||||
private function isUserAgentConsistent(UserAgent $original, UserAgent $current): bool
|
||||
{
|
||||
// Allow minor version changes but detect major browser changes
|
||||
$originalBrowser = $this->extractBrowser($original);
|
||||
$currentBrowser = $this->extractBrowser($current);
|
||||
|
||||
return $originalBrowser === $currentBrowser;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Token Security
|
||||
|
||||
### Remember Token Security with Hash Value Object
|
||||
|
||||
```php
|
||||
final readonly class RememberTokenSecurity
|
||||
{
|
||||
public function createRememberToken(string $userId): RememberToken
|
||||
{
|
||||
// Generate cryptographically secure token
|
||||
$tokenValue = bin2hex(random_bytes(32)); // 256-bit token
|
||||
|
||||
// Hash token using Hash Value Object for type safety
|
||||
$tokenHash = Hash::sha256($tokenValue);
|
||||
|
||||
$rememberToken = new RememberToken(
|
||||
hash: $tokenHash,
|
||||
userId: $userId,
|
||||
createdAt: new \DateTimeImmutable(),
|
||||
expiresAt: new \DateTimeImmutable('+30 days'),
|
||||
lastUsed: null
|
||||
);
|
||||
|
||||
$this->repository->storeRememberToken($rememberToken);
|
||||
|
||||
// Return token with plain text value (only shown once)
|
||||
return $rememberToken->withPlainTextValue($tokenValue);
|
||||
}
|
||||
|
||||
public function validateRememberToken(string $tokenValue): RememberTokenValidation
|
||||
{
|
||||
// Hash provided token for comparison
|
||||
$tokenHash = Hash::sha256($tokenValue);
|
||||
|
||||
$rememberToken = $this->repository->findRememberToken($tokenHash);
|
||||
if (!$rememberToken) {
|
||||
$this->logSecurityEvent('invalid_remember_token', [
|
||||
'token_hash' => $tokenHash->toShort(8) // Only log first 8 chars
|
||||
]);
|
||||
|
||||
return RememberTokenValidation::invalid();
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if ($rememberToken->isExpired()) {
|
||||
$this->repository->deleteRememberToken($tokenHash);
|
||||
return RememberTokenValidation::expired();
|
||||
}
|
||||
|
||||
// Update last used timestamp
|
||||
$this->repository->updateRememberTokenUsage($tokenHash);
|
||||
|
||||
return RememberTokenValidation::valid($rememberToken);
|
||||
}
|
||||
|
||||
public function rotateRememberToken(Hash $oldTokenHash): RememberToken
|
||||
{
|
||||
$oldToken = $this->repository->findRememberToken($oldTokenHash);
|
||||
if (!$oldToken) {
|
||||
throw new SecurityException('Cannot rotate non-existent token');
|
||||
}
|
||||
|
||||
// Create new token
|
||||
$newToken = $this->createRememberToken($oldToken->getUserId());
|
||||
|
||||
// Delete old token
|
||||
$this->repository->deleteRememberToken($oldTokenHash);
|
||||
|
||||
$this->logSecurityEvent('remember_token_rotated', [
|
||||
'user_id' => $oldToken->getUserId(),
|
||||
'old_token_created' => $oldToken->getCreatedAt()->format('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
return $newToken;
|
||||
}
|
||||
}
|
||||
|
||||
// Benefits of using Hash Value Object:
|
||||
// ✅ Type safety - tokens can't be confused with other strings
|
||||
// ✅ Algorithm specification - explicit SHA-256 usage
|
||||
// ✅ Timing-safe comparison - uses hash_equals() internally
|
||||
// ✅ Validation - ensures proper hash format and length
|
||||
// ✅ Framework consistency - follows value object patterns
|
||||
```
|
||||
|
||||
## Rate Limiting & Brute Force Protection
|
||||
|
||||
### Advanced Rate Limiting
|
||||
|
||||
```php
|
||||
final readonly class AdvancedRateLimiter implements RateLimitService
|
||||
{
|
||||
public function __construct(
|
||||
private Cache $cache,
|
||||
private Logger $logger,
|
||||
private RateLimitConfig $config
|
||||
) {}
|
||||
|
||||
public function checkRateLimit(
|
||||
IpAddress $ipAddress,
|
||||
string $action,
|
||||
string $identifier = null
|
||||
): RateLimitResult {
|
||||
// Multiple rate limiting strategies
|
||||
$checks = [
|
||||
$this->checkIpBasedLimit($ipAddress, $action),
|
||||
$this->checkIdentifierBasedLimit($identifier, $action),
|
||||
$this->checkGlobalLimit($action),
|
||||
$this->checkAdaptiveLimit($ipAddress, $action)
|
||||
];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if ($check->isLimited()) {
|
||||
$this->logRateLimitViolation($ipAddress, $action, $check);
|
||||
return $check;
|
||||
}
|
||||
}
|
||||
|
||||
// Record successful attempt
|
||||
$this->recordAttempt($ipAddress, $action, $identifier);
|
||||
|
||||
return RateLimitResult::allowed();
|
||||
}
|
||||
|
||||
private function checkAdaptiveLimit(IpAddress $ipAddress, string $action): RateLimitResult
|
||||
{
|
||||
$suspiciousScore = $this->calculateSuspiciousScore($ipAddress);
|
||||
|
||||
// Adaptive limits based on suspicious activity
|
||||
$maxAttempts = match (true) {
|
||||
$suspiciousScore > 80 => 1, // Highly suspicious
|
||||
$suspiciousScore > 60 => 2, // Suspicious
|
||||
$suspiciousScore > 40 => 3, // Moderately suspicious
|
||||
default => 5 // Normal
|
||||
};
|
||||
|
||||
$key = "adaptive_limit:{$action}:" . $ipAddress->toString();
|
||||
$attempts = $this->cache->get($key, 0);
|
||||
|
||||
if ($attempts >= $maxAttempts) {
|
||||
return RateLimitResult::limited(
|
||||
reason: 'Adaptive rate limit exceeded',
|
||||
retryAfter: 900,
|
||||
metadata: ['suspicious_score' => $suspiciousScore]
|
||||
);
|
||||
}
|
||||
|
||||
return RateLimitResult::allowed();
|
||||
}
|
||||
|
||||
private function calculateSuspiciousScore(IpAddress $ipAddress): int
|
||||
{
|
||||
$score = 0;
|
||||
|
||||
// Check for various suspicious indicators
|
||||
if ($this->isFromTorNetwork($ipAddress)) $score += 30;
|
||||
if ($this->isFromVpnProvider($ipAddress)) $score += 15;
|
||||
if ($this->hasRecentFailures($ipAddress)) $score += 20;
|
||||
if ($this->isFromUncommonGeolocation($ipAddress)) $score += 10;
|
||||
if ($this->hasRapidRequests($ipAddress)) $score += 25;
|
||||
|
||||
return min(100, $score);
|
||||
}
|
||||
|
||||
private function logRateLimitViolation(
|
||||
IpAddress $ipAddress,
|
||||
string $action,
|
||||
RateLimitResult $result
|
||||
): void {
|
||||
$this->logger->warning('Rate limit violation', [
|
||||
'ip_address' => (string) $ipAddress,
|
||||
'action' => $action,
|
||||
'reason' => $result->getReason(),
|
||||
'retry_after' => $result->getRetryAfter(),
|
||||
'metadata' => $result->getMetadata()
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Account Lockout Protection
|
||||
|
||||
```php
|
||||
final readonly class AccountLockoutService
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepository $repository,
|
||||
private Logger $logger,
|
||||
private NotificationService $notifications
|
||||
) {}
|
||||
|
||||
public function handleFailedLogin(string $identifier, IpAddress $ipAddress): void
|
||||
{
|
||||
$user = $this->repository->findByIdentifier($identifier);
|
||||
if (!$user) {
|
||||
return; // Don't reveal if user exists
|
||||
}
|
||||
|
||||
$attempts = $this->repository->incrementFailedAttempts($user->getId());
|
||||
|
||||
// Progressive lockout strategy
|
||||
$lockoutDuration = $this->calculateLockoutDuration($attempts);
|
||||
|
||||
if ($attempts >= 3) {
|
||||
$this->repository->lockAccount($user->getId(), $lockoutDuration);
|
||||
|
||||
$this->logger->warning('Account locked due to failed attempts', [
|
||||
'user_id' => $user->getId(),
|
||||
'attempts' => $attempts,
|
||||
'lockout_duration' => $lockoutDuration,
|
||||
'ip_address' => (string) $ipAddress
|
||||
]);
|
||||
|
||||
// Notify user of lockout (rate limited to prevent abuse)
|
||||
$this->sendLockoutNotification($user, $attempts, $lockoutDuration);
|
||||
}
|
||||
}
|
||||
|
||||
private function calculateLockoutDuration(int $attempts): int
|
||||
{
|
||||
// Exponential backoff with jitter
|
||||
return match (true) {
|
||||
$attempts >= 10 => 3600 + random_int(0, 1800), // 1-2.5 hours
|
||||
$attempts >= 7 => 1800 + random_int(0, 900), // 30-45 minutes
|
||||
$attempts >= 5 => 900 + random_int(0, 300), // 15-20 minutes
|
||||
$attempts >= 3 => 300 + random_int(0, 120), // 5-7 minutes
|
||||
default => 0
|
||||
};
|
||||
}
|
||||
|
||||
private function sendLockoutNotification(
|
||||
User $user,
|
||||
int $attempts,
|
||||
int $lockoutDuration
|
||||
): void {
|
||||
// Rate limit notifications to prevent spam
|
||||
$notificationKey = "lockout_notification:" . $user->getId();
|
||||
if ($this->cache->has($notificationKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->cache->put($notificationKey, true, 300); // 5 minutes
|
||||
|
||||
$this->notifications->sendSecurityAlert($user->getEmail(), [
|
||||
'type' => 'account_lockout',
|
||||
'attempts' => $attempts,
|
||||
'lockout_duration_minutes' => ceil($lockoutDuration / 60),
|
||||
'timestamp' => new \DateTimeImmutable()
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-Factor Authentication Security
|
||||
|
||||
### TOTP Implementation with Security Features
|
||||
|
||||
```php
|
||||
final readonly class SecureTotpService
|
||||
{
|
||||
private const int SECRET_LENGTH = 32; // 160-bit secret
|
||||
private const int WINDOW_SIZE = 1; // Allow 1 time step tolerance
|
||||
private const int TIME_STEP = 30; // 30-second time steps
|
||||
|
||||
public function generateSecret(): string
|
||||
{
|
||||
return Base32::encode(random_bytes(self::SECRET_LENGTH));
|
||||
}
|
||||
|
||||
public function verify(string $code, string $secret, ?int $timestamp = null): bool
|
||||
{
|
||||
$timestamp = $timestamp ?? time();
|
||||
$timeStep = intval($timestamp / self::TIME_STEP);
|
||||
|
||||
// Check current and adjacent time windows to account for clock drift
|
||||
for ($i = -self::WINDOW_SIZE; $i <= self::WINDOW_SIZE; $i++) {
|
||||
$calculatedCode = $this->calculateTotp($secret, $timeStep + $i);
|
||||
|
||||
if (hash_equals($code, $calculatedCode)) {
|
||||
// Prevent replay attacks by storing used codes
|
||||
$this->markCodeAsUsed($secret, $timeStep + $i);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function calculateTotp(string $secret, int $timeStep): string
|
||||
{
|
||||
$binarySecret = Base32::decode($secret);
|
||||
$timeBytes = pack('N*', 0) . pack('N*', $timeStep);
|
||||
|
||||
$hash = hash_hmac('sha1', $timeBytes, $binarySecret, true);
|
||||
$offset = ord($hash[19]) & 0xf;
|
||||
|
||||
$code = (
|
||||
((ord($hash[$offset]) & 0x7f) << 24) |
|
||||
((ord($hash[$offset + 1]) & 0xff) << 16) |
|
||||
((ord($hash[$offset + 2]) & 0xff) << 8) |
|
||||
(ord($hash[$offset + 3]) & 0xff)
|
||||
) % 1000000;
|
||||
|
||||
return str_pad((string) $code, 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function markCodeAsUsed(string $secret, int $timeStep): void
|
||||
{
|
||||
// Prevent replay attacks
|
||||
$key = 'totp_used:' . Hash::sha256($secret . ':' . $timeStep)->toShort(16);
|
||||
$this->cache->put($key, true, self::TIME_STEP * 2);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Backup Code Security
|
||||
|
||||
```php
|
||||
final readonly class BackupCodeService
|
||||
{
|
||||
private const int CODE_LENGTH = 8;
|
||||
private const int CODE_COUNT = 8;
|
||||
|
||||
public function generateBackupCodes(string $userId): array
|
||||
{
|
||||
$codes = [];
|
||||
|
||||
for ($i = 0; $i < self::CODE_COUNT; $i++) {
|
||||
$code = $this->generateHumanReadableCode();
|
||||
$codes[] = $code;
|
||||
|
||||
// Store hashed version
|
||||
$backupCode = new MfaBackupCode(
|
||||
userId: $userId,
|
||||
codeHash: Hash::sha256($code),
|
||||
createdAt: new \DateTimeImmutable(),
|
||||
usedAt: null
|
||||
);
|
||||
|
||||
$this->repository->storeBackupCode($backupCode);
|
||||
}
|
||||
|
||||
return $codes;
|
||||
}
|
||||
|
||||
public function verifyBackupCode(string $userId, string $code): bool
|
||||
{
|
||||
$codeHash = Hash::sha256($code);
|
||||
$backupCode = $this->repository->findBackupCode($userId, $codeHash);
|
||||
|
||||
if (!$backupCode || $backupCode->isUsed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mark as used (one-time use only)
|
||||
$this->repository->markBackupCodeAsUsed($backupCode, new \DateTimeImmutable());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function generateHumanReadableCode(): string
|
||||
{
|
||||
// Generate readable codes avoiding confusing characters
|
||||
$chars = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; // No 0, 1, I, O
|
||||
$code = '';
|
||||
|
||||
for ($i = 0; $i < self::CODE_LENGTH; $i++) {
|
||||
$code .= $chars[random_int(0, strlen($chars) - 1)];
|
||||
}
|
||||
|
||||
// Format as XXXX-XXXX for readability
|
||||
return substr($code, 0, 4) . '-' . substr($code, 4, 4);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring & Incident Response
|
||||
|
||||
### Security Event Monitoring
|
||||
|
||||
```php
|
||||
final readonly class SecurityEventMonitor
|
||||
{
|
||||
public function __construct(
|
||||
private Logger $securityLogger,
|
||||
private AlertingService $alerting,
|
||||
private MetricsCollector $metrics
|
||||
) {}
|
||||
|
||||
public function recordSecurityEvent(SecurityEvent $event): void
|
||||
{
|
||||
// Log all security events
|
||||
$this->securityLogger->warning('Security event recorded', [
|
||||
'event_type' => $event->getType(),
|
||||
'severity' => $event->getSeverity(),
|
||||
'user_id' => $event->getUserId(),
|
||||
'ip_address' => (string) $event->getIpAddress(),
|
||||
'user_agent' => (string) $event->getUserAgent(),
|
||||
'metadata' => $event->getMetadata(),
|
||||
'timestamp' => $event->getTimestamp()->format('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
// Update security metrics
|
||||
$this->metrics->increment('auth.security_events', [
|
||||
'type' => $event->getType(),
|
||||
'severity' => $event->getSeverity()
|
||||
]);
|
||||
|
||||
// Send alerts for critical events
|
||||
if ($event->isCritical()) {
|
||||
$this->alerting->sendSecurityAlert($event);
|
||||
}
|
||||
|
||||
// Check for attack patterns
|
||||
$this->analyzeForAttackPatterns($event);
|
||||
}
|
||||
|
||||
private function analyzeForAttackPatterns(SecurityEvent $event): void
|
||||
{
|
||||
$ipAddress = $event->getIpAddress();
|
||||
$recentEvents = $this->getRecentSecurityEvents($ipAddress, minutes: 10);
|
||||
|
||||
// Detect brute force attacks
|
||||
$failedLogins = array_filter($recentEvents,
|
||||
fn($e) => $e->getType() === 'authentication_failed'
|
||||
);
|
||||
|
||||
if (count($failedLogins) >= 5) {
|
||||
$this->alerting->sendAlert(new BruteForceDetectionAlert(
|
||||
ipAddress: $ipAddress,
|
||||
attemptCount: count($failedLogins),
|
||||
timeWindow: 10
|
||||
));
|
||||
}
|
||||
|
||||
// Detect credential stuffing
|
||||
$uniqueIdentifiers = array_unique(array_map(
|
||||
fn($e) => $e->getMetadata()['identifier'] ?? null,
|
||||
$failedLogins
|
||||
));
|
||||
|
||||
if (count($uniqueIdentifiers) >= 10) {
|
||||
$this->alerting->sendAlert(new CredentialStuffingAlert(
|
||||
ipAddress: $ipAddress,
|
||||
uniqueIdentifiers: count($uniqueIdentifiers)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Automated Response System
|
||||
|
||||
```php
|
||||
final readonly class AutomatedSecurityResponse
|
||||
{
|
||||
public function handleSecurityEvent(SecurityEvent $event): void
|
||||
{
|
||||
match ($event->getType()) {
|
||||
'brute_force_detected' => $this->handleBruteForce($event),
|
||||
'credential_stuffing_detected' => $this->handleCredentialStuffing($event),
|
||||
'session_hijacking_suspected' => $this->handleSessionHijacking($event),
|
||||
'suspicious_login_pattern' => $this->handleSuspiciousLogin($event),
|
||||
default => null
|
||||
};
|
||||
}
|
||||
|
||||
private function handleBruteForce(SecurityEvent $event): void
|
||||
{
|
||||
$ipAddress = $event->getIpAddress();
|
||||
|
||||
// Immediately block IP for 1 hour
|
||||
$this->firewall->blockIpAddress($ipAddress, duration: 3600);
|
||||
|
||||
// Increase monitoring for this IP
|
||||
$this->monitoring->increaseWatchLevel($ipAddress);
|
||||
|
||||
// Notify security team
|
||||
$this->alerting->sendUrgentAlert(new BruteForceBlockAlert($ipAddress));
|
||||
}
|
||||
|
||||
private function handleSessionHijacking(SecurityEvent $event): void
|
||||
{
|
||||
$userId = $event->getUserId();
|
||||
$sessionId = $event->getMetadata()['session_id'];
|
||||
|
||||
// Immediately terminate all user sessions
|
||||
$this->authService->logoutAll($userId);
|
||||
|
||||
// Require password reset
|
||||
$this->userService->requirePasswordReset($userId);
|
||||
|
||||
// Send security notification to user
|
||||
$user = $this->userRepository->findById($userId);
|
||||
$this->notifications->sendSecurityBreach($user->getEmail(), [
|
||||
'incident_type' => 'session_hijacking',
|
||||
'affected_session' => $sessionId,
|
||||
'timestamp' => $event->getTimestamp()
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Compliance & Audit
|
||||
|
||||
### GDPR Compliance
|
||||
|
||||
```php
|
||||
final readonly class GdprCompliantAuthService
|
||||
{
|
||||
public function processPersonalData(User $user, string $purpose): void
|
||||
{
|
||||
// Log data processing for GDPR audit trail
|
||||
$this->auditLogger->info('Personal data processed', [
|
||||
'user_id' => $user->getId(),
|
||||
'data_types' => ['email', 'login_timestamps', 'ip_addresses'],
|
||||
'processing_purpose' => $purpose,
|
||||
'legal_basis' => 'legitimate_interest', // or 'consent'
|
||||
'retention_period' => '2_years'
|
||||
]);
|
||||
}
|
||||
|
||||
public function exportUserData(string $userId): UserDataExport
|
||||
{
|
||||
$user = $this->repository->findById($userId);
|
||||
if (!$user) {
|
||||
throw new UserNotFoundException($userId);
|
||||
}
|
||||
|
||||
return new UserDataExport([
|
||||
'personal_data' => [
|
||||
'user_id' => $user->getId(),
|
||||
'email' => (string) $user->getEmail(),
|
||||
'created_at' => $user->getCreatedAt()->format('c'),
|
||||
'last_login' => $user->getLastLoginAt()?->format('c')
|
||||
],
|
||||
'authentication_data' => [
|
||||
'password_last_changed' => $user->getPasswordChangedAt()?->format('c'),
|
||||
'mfa_enabled' => $user->hasMfaEnabled(),
|
||||
'failed_login_attempts' => $this->repository->getFailedLoginAttempts($userId)
|
||||
],
|
||||
'session_data' => $this->getSessionHistory($userId),
|
||||
'security_events' => $this->getSecurityEventHistory($userId)
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteUserData(string $userId): void
|
||||
{
|
||||
// GDPR right to erasure (right to be forgotten)
|
||||
$this->repository->anonymizeUser($userId);
|
||||
$this->repository->deleteAllUserSessions($userId);
|
||||
$this->repository->deleteAllUserRememberTokens($userId);
|
||||
$this->repository->deleteUserSecurityEvents($userId);
|
||||
|
||||
$this->auditLogger->info('User data deleted per GDPR request', [
|
||||
'user_id' => $userId,
|
||||
'deletion_timestamp' => (new \DateTimeImmutable())->format('c')
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Security Audit Logging
|
||||
|
||||
```php
|
||||
final readonly class SecurityAuditLogger
|
||||
{
|
||||
public function logAuthenticationEvent(string $eventType, array $context): void
|
||||
{
|
||||
$auditRecord = [
|
||||
'event_type' => $eventType,
|
||||
'timestamp' => (new \DateTimeImmutable())->format('c'),
|
||||
'user_id' => $context['user_id'] ?? null,
|
||||
'session_id' => $context['session_id'] ?? null,
|
||||
'ip_address' => $context['ip_address'] ?? null,
|
||||
'user_agent' => $context['user_agent'] ?? null,
|
||||
'success' => $context['success'] ?? false,
|
||||
'failure_reason' => $context['failure_reason'] ?? null,
|
||||
'risk_score' => $this->calculateRiskScore($context)
|
||||
];
|
||||
|
||||
// Store in secure audit log (append-only, tamper-evident)
|
||||
$this->auditStorage->append($auditRecord);
|
||||
|
||||
// Forward to SIEM if configured
|
||||
if ($this->siemForwarder) {
|
||||
$this->siemForwarder->forward($auditRecord);
|
||||
}
|
||||
}
|
||||
|
||||
private function calculateRiskScore(array $context): int
|
||||
{
|
||||
$score = 0;
|
||||
|
||||
// Location-based risk
|
||||
if (isset($context['ip_address'])) {
|
||||
$location = $this->geolocator->locate($context['ip_address']);
|
||||
if ($location->isHighRiskCountry()) $score += 25;
|
||||
if ($location->isFromTor()) $score += 50;
|
||||
}
|
||||
|
||||
// Time-based risk
|
||||
$hour = (int) date('H');
|
||||
if ($hour < 6 || $hour > 22) $score += 10; // Outside business hours
|
||||
|
||||
// Failure patterns
|
||||
if (!($context['success'] ?? true)) $score += 20;
|
||||
|
||||
return min(100, $score);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user