Files
michaelschiemer/tests/Security/AuthenticationTests/BruteForceProtectionTest.php
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

410 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Security\AuthenticationTests;
use Tests\Security\SecurityTestCase;
/**
* Brute Force Protection tests
*
* Tests rate limiting, account lockout, and brute force attack prevention
*/
final readonly class BruteForceProtectionTest extends SecurityTestCase
{
/**
* Test rate limiting on login attempts
*/
public function testEnforcesRateLimitOnLoginAttempts(): void
{
$ipAddress = '203.0.113.42';
$maxAttempts = 5;
$windowSeconds = 300; // 5 minutes
// Simulate failed login attempts
$attempts = [];
for ($i = 0; $i < 10; $i++) {
$attempts[] = [
'ip' => $ipAddress,
'timestamp' => time() - ($i * 30), // 30 seconds apart
'success' => false
];
}
$isRateLimited = $this->checkRateLimit(
ipAddress: $ipAddress,
attempts: $attempts,
maxAttempts: $maxAttempts,
windowSeconds: $windowSeconds
);
if (!$isRateLimited) {
throw new \RuntimeException(
'Rate limiting not enforced after ' . count($attempts) . ' attempts'
);
}
echo "✅ Rate limiting enforced ({$maxAttempts} attempts per {$windowSeconds}s)\n";
}
/**
* Test account lockout after failed attempts
*/
public function testLocksAccountAfterFailedAttempts(): void
{
$username = 'testuser@example.com';
$maxFailedAttempts = 5;
$lockoutDurationSeconds = 900; // 15 minutes
// Simulate failed attempts
$failedAttempts = [];
for ($i = 0; $i < 6; $i++) {
$failedAttempts[] = [
'username' => $username,
'timestamp' => time() - ($i * 10),
'success' => false
];
}
$lockoutInfo = $this->checkAccountLockout(
username: $username,
attempts: $failedAttempts,
maxFailedAttempts: $maxFailedAttempts,
lockoutDuration: $lockoutDurationSeconds
);
if (!$lockoutInfo['is_locked']) {
throw new \RuntimeException(
'Account not locked after ' . count($failedAttempts) . ' failed attempts'
);
}
echo "✅ Account locked after {$maxFailedAttempts} failed attempts\n";
echo " Lockout duration: {$lockoutDurationSeconds}s\n";
}
/**
* Test progressive delay on failed attempts
*/
public function testImplementsProgressiveDelay(): void
{
$attempts = [1, 2, 3, 4, 5];
$delays = [];
foreach ($attempts as $attemptNumber) {
$delay = $this->calculateProgressiveDelay($attemptNumber);
$delays[$attemptNumber] = $delay;
}
// Verify delay increases with each attempt
for ($i = 1; $i < count($delays); $i++) {
if ($delays[$i] <= $delays[$i - 1]) {
throw new \RuntimeException(
"Progressive delay not increasing: attempt {$i} has delay {$delays[$i]}s, " .
"previous was {$delays[$i-1]}s"
);
}
}
echo "✅ Progressive delay implemented:\n";
foreach ($delays as $attempt => $delay) {
echo " Attempt {$attempt}: {$delay}s delay\n";
}
}
/**
* Test CAPTCHA requirement after suspicious activity
*/
public function testRequiresCaptchaAfterSuspiciousActivity(): void
{
$ipAddress = '203.0.113.42';
$failedAttempts = 3;
$requiresCaptcha = $this->shouldRequireCaptcha(
ipAddress: $ipAddress,
failedAttempts: $failedAttempts,
captchaThreshold: 3
);
if (!$requiresCaptcha) {
throw new \RuntimeException(
'CAPTCHA not required after ' . $failedAttempts . ' failed attempts'
);
}
echo "✅ CAPTCHA required after {$failedAttempts} failed attempts\n";
}
/**
* Test distributed brute force detection
*/
public function testDetectsDistributedBruteForce(): void
{
$username = 'admin@example.com';
// Simulate attempts from multiple IPs
$attempts = [
['ip' => '203.0.113.1', 'username' => $username, 'timestamp' => time() - 100],
['ip' => '203.0.113.2', 'username' => $username, 'timestamp' => time() - 90],
['ip' => '203.0.113.3', 'username' => $username, 'timestamp' => time() - 80],
['ip' => '203.0.113.4', 'username' => $username, 'timestamp' => time() - 70],
['ip' => '203.0.113.5', 'username' => $username, 'timestamp' => time() - 60],
['ip' => '203.0.113.6', 'username' => $username, 'timestamp' => time() - 50],
];
$isDistributedAttack = $this->detectDistributedBruteForce(
attempts: $attempts,
username: $username,
windowSeconds: 300,
uniqueIpThreshold: 5
);
if (!$isDistributedAttack) {
throw new \RuntimeException(
'Distributed brute force attack not detected (' . count($attempts) . ' attempts from different IPs)'
);
}
echo "✅ Distributed brute force attack detected\n";
echo " " . count($attempts) . " attempts from " . count(array_unique(array_column($attempts, 'ip'))) . " unique IPs\n";
}
/**
* Test password spray attack detection
*/
public function testDetectsPasswordSprayAttack(): void
{
// Simulate password spray: same password tried against multiple accounts
$attempts = [
['username' => 'user1@example.com', 'password' => 'Password123!', 'timestamp' => time() - 100],
['username' => 'user2@example.com', 'password' => 'Password123!', 'timestamp' => time() - 90],
['username' => 'user3@example.com', 'password' => 'Password123!', 'timestamp' => time() - 80],
['username' => 'user4@example.com', 'password' => 'Password123!', 'timestamp' => time() - 70],
['username' => 'user5@example.com', 'password' => 'Password123!', 'timestamp' => time() - 60],
];
$isPasswordSpray = $this->detectPasswordSpray(
attempts: $attempts,
windowSeconds: 300,
uniqueUsernameThreshold: 5
);
if (!$isPasswordSpray) {
throw new \RuntimeException(
'Password spray attack not detected (' . count($attempts) . ' attempts with same password)'
);
}
echo "✅ Password spray attack detected\n";
echo " Same password tried against " . count($attempts) . " different accounts\n";
}
/**
* Test successful login resets attempt counter
*/
public function testSuccessfulLoginResetsAttemptCounter(): void
{
$username = 'testuser@example.com';
// Failed attempts
$attempts = [
['username' => $username, 'success' => false, 'timestamp' => time() - 200],
['username' => $username, 'success' => false, 'timestamp' => time() - 150],
['username' => $username, 'success' => false, 'timestamp' => time() - 100],
// Successful login
['username' => $username, 'success' => true, 'timestamp' => time() - 50],
];
$failedCount = $this->getFailedAttemptCount($username, $attempts);
if ($failedCount !== 0) {
throw new \RuntimeException(
'Failed attempt counter not reset after successful login (count: ' . $failedCount . ')'
);
}
echo "✅ Failed attempt counter reset after successful login\n";
}
/**
* Run all brute force protection tests
*/
public function runAllTests(): array
{
$results = [];
try {
$this->testEnforcesRateLimitOnLoginAttempts();
$results['rate_limiting'] = 'PASS';
} catch (\Exception $e) {
$results['rate_limiting'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testLocksAccountAfterFailedAttempts();
$results['account_lockout'] = 'PASS';
} catch (\Exception $e) {
$results['account_lockout'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testImplementsProgressiveDelay();
$results['progressive_delay'] = 'PASS';
} catch (\Exception $e) {
$results['progressive_delay'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testRequiresCaptchaAfterSuspiciousActivity();
$results['captcha_requirement'] = 'PASS';
} catch (\Exception $e) {
$results['captcha_requirement'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testDetectsDistributedBruteForce();
$results['distributed_attack'] = 'PASS';
} catch (\Exception $e) {
$results['distributed_attack'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testDetectsPasswordSprayAttack();
$results['password_spray'] = 'PASS';
} catch (\Exception $e) {
$results['password_spray'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testSuccessfulLoginResetsAttemptCounter();
$results['counter_reset'] = 'PASS';
} catch (\Exception $e) {
$results['counter_reset'] = 'FAIL: ' . $e->getMessage();
}
return $results;
}
private function checkRateLimit(
string $ipAddress,
array $attempts,
int $maxAttempts,
int $windowSeconds
): bool {
$recentAttempts = array_filter($attempts, function ($attempt) use ($ipAddress, $windowSeconds) {
return $attempt['ip'] === $ipAddress
&& (time() - $attempt['timestamp']) <= $windowSeconds
&& !$attempt['success'];
});
return count($recentAttempts) >= $maxAttempts;
}
private function checkAccountLockout(
string $username,
array $attempts,
int $maxFailedAttempts,
int $lockoutDuration
): array {
$failedAttempts = array_filter($attempts, function ($attempt) use ($username) {
return $attempt['username'] === $username && !$attempt['success'];
});
$isLocked = count($failedAttempts) >= $maxFailedAttempts;
return [
'is_locked' => $isLocked,
'failed_attempts' => count($failedAttempts),
'unlock_at' => $isLocked ? time() + $lockoutDuration : null
];
}
private function calculateProgressiveDelay(int $attemptNumber): int
{
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
return min(2 ** ($attemptNumber - 1), 16);
}
private function shouldRequireCaptcha(
string $ipAddress,
int $failedAttempts,
int $captchaThreshold
): bool {
return $failedAttempts >= $captchaThreshold;
}
private function detectDistributedBruteForce(
array $attempts,
string $username,
int $windowSeconds,
int $uniqueIpThreshold
): bool {
$recentAttempts = array_filter($attempts, function ($attempt) use ($username, $windowSeconds) {
return $attempt['username'] === $username
&& (time() - $attempt['timestamp']) <= $windowSeconds;
});
$uniqueIps = array_unique(array_column($recentAttempts, 'ip'));
return count($uniqueIps) >= $uniqueIpThreshold;
}
private function detectPasswordSpray(
array $attempts,
int $windowSeconds,
int $uniqueUsernameThreshold
): bool {
$recentAttempts = array_filter($attempts, function ($attempt) use ($windowSeconds) {
return (time() - $attempt['timestamp']) <= $windowSeconds;
});
// Group by password
$passwordGroups = [];
foreach ($recentAttempts as $attempt) {
$password = $attempt['password'];
if (!isset($passwordGroups[$password])) {
$passwordGroups[$password] = [];
}
$passwordGroups[$password][] = $attempt['username'];
}
// Check if any password was tried against multiple usernames
foreach ($passwordGroups as $password => $usernames) {
$uniqueUsernames = array_unique($usernames);
if (count($uniqueUsernames) >= $uniqueUsernameThreshold) {
return true;
}
}
return false;
}
private function getFailedAttemptCount(string $username, array $attempts): int
{
// Find last successful login
$lastSuccessIndex = null;
foreach ($attempts as $index => $attempt) {
if ($attempt['username'] === $username && $attempt['success']) {
$lastSuccessIndex = $index;
}
}
// Count failed attempts after last successful login
if ($lastSuccessIndex === null) {
// No successful login, count all failed attempts
return count(array_filter($attempts, fn($a) => $a['username'] === $username && !$a['success']));
}
// Count failed attempts after last successful login
$failedCount = 0;
for ($i = $lastSuccessIndex + 1; $i < count($attempts); $i++) {
if ($attempts[$i]['username'] === $username && !$attempts[$i]['success']) {
$failedCount++;
}
}
return $failedCount;
}
}