- 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.
410 lines
13 KiB
PHP
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;
|
|
}
|
|
}
|