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.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,409 @@
<?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;
}
}

View File

@@ -0,0 +1,325 @@
<?php
declare(strict_types=1);
namespace Tests\Security\AuthenticationTests;
use Tests\Security\SecurityTestCase;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
/**
* Session Security tests
*
* Tests session hijacking, session fixation, and session timeout mechanisms
*/
final readonly class SessionSecurityTest extends SecurityTestCase
{
/**
* Test session hijacking prevention
*/
public function testPreventsSessionHijacking(): void
{
// Simulate session creation
session_start();
$originalSessionId = session_id();
$_SESSION['user_id'] = 123;
$_SESSION['ip_address'] = '192.168.1.100';
$_SESSION['user_agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
// Simulate hijack attempt from different IP
$hijackAttempt = $this->detectSessionHijack(
sessionIp: '192.168.1.100',
requestIp: '203.0.113.42', // Different IP
sessionUserAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
requestUserAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
);
if (!$hijackAttempt) {
throw new \RuntimeException('Session hijacking from different IP not detected');
}
echo "✅ Session hijacking from different IP detected\n";
// Simulate hijack attempt from different User-Agent
$hijackAttempt = $this->detectSessionHijack(
sessionIp: '192.168.1.100',
requestIp: '192.168.1.100',
sessionUserAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
requestUserAgent: 'curl/7.68.0' // Different User-Agent
);
if (!$hijackAttempt) {
throw new \RuntimeException('Session hijacking from different User-Agent not detected');
}
echo "✅ Session hijacking from different User-Agent detected\n";
}
/**
* Test session fixation prevention
*/
public function testPreventsSessionFixation(): void
{
// Attacker provides session ID
session_id('attacker_controlled_session_id');
session_start();
$oldSessionId = session_id();
// User logs in - session should be regenerated
$this->regenerateSessionOnLogin();
$newSessionId = session_id();
if ($oldSessionId === $newSessionId) {
throw new \RuntimeException('Session ID not regenerated after login - vulnerable to session fixation');
}
echo "✅ Session ID regenerated after login (fixation prevention)\n";
}
/**
* Test session timeout
*/
public function testEnforcesSessionTimeout(): void
{
session_start();
$_SESSION['last_activity'] = time() - 3600; // 1 hour ago
$isExpired = $this->checkSessionTimeout(
lastActivity: $_SESSION['last_activity'],
timeoutSeconds: 1800 // 30 minute timeout
);
if (!$isExpired) {
throw new \RuntimeException('Expired session not detected');
}
echo "✅ Session timeout enforced (30 minute inactivity)\n";
}
/**
* Test session data integrity
*/
public function testValidatesSessionDataIntegrity(): void
{
session_start();
$_SESSION['user_id'] = 123;
$_SESSION['role'] = 'user';
// Simulate tampering
$_SESSION['role'] = 'admin'; // Privilege escalation attempt
$isTampered = $this->detectSessionTampering(
originalData: ['user_id' => 123, 'role' => 'user'],
currentData: ['user_id' => 123, 'role' => 'admin']
);
if (!$isTampered) {
throw new \RuntimeException('Session tampering not detected');
}
echo "✅ Session data tampering detected\n";
}
/**
* Test session cookie security attributes
*/
public function testSessionCookieSecurityAttributes(): void
{
$cookieParams = session_get_cookie_params();
$issues = [];
// Check HttpOnly flag
if (!$cookieParams['httponly']) {
$issues[] = 'HttpOnly flag not set (vulnerable to XSS)';
}
// Check Secure flag
if (!$cookieParams['secure']) {
$issues[] = 'Secure flag not set (session can be transmitted over HTTP)';
}
// Check SameSite attribute
if (empty($cookieParams['samesite']) || !in_array($cookieParams['samesite'], ['Strict', 'Lax'])) {
$issues[] = 'SameSite attribute not properly set (vulnerable to CSRF)';
}
if (!empty($issues)) {
throw new \RuntimeException(
"Session cookie security issues:\n" . implode("\n", $issues)
);
}
echo "✅ Session cookie has proper security attributes (HttpOnly, Secure, SameSite)\n";
}
/**
* Test concurrent session limit
*/
public function testEnforcesConcurrentSessionLimit(): void
{
$userId = 123;
$maxSessions = 3;
// Simulate active sessions
$activeSessions = [
'session1' => ['created' => time() - 100],
'session2' => ['created' => time() - 200],
'session3' => ['created' => time() - 300],
];
// Try to create 4th session
$canCreateSession = $this->checkConcurrentSessionLimit(
userId: $userId,
activeSessions: $activeSessions,
maxSessions: $maxSessions
);
if ($canCreateSession) {
throw new \RuntimeException('Concurrent session limit not enforced');
}
echo "✅ Concurrent session limit enforced (max {$maxSessions} sessions)\n";
}
/**
* Test session destruction on logout
*/
public function testProperSessionDestruction(): void
{
session_start();
$_SESSION['user_id'] = 123;
$_SESSION['authenticated'] = true;
// Logout
$this->destroySession();
if (isset($_SESSION['user_id']) || isset($_SESSION['authenticated'])) {
throw new \RuntimeException('Session data not properly destroyed on logout');
}
if (session_status() === PHP_SESSION_ACTIVE) {
throw new \RuntimeException('Session still active after logout');
}
echo "✅ Session properly destroyed on logout\n";
}
/**
* Run all session security tests
*/
public function runAllTests(): array
{
$results = [];
try {
$this->testPreventsSessionHijacking();
$results['session_hijacking'] = 'PASS';
} catch (\Exception $e) {
$results['session_hijacking'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testPreventsSessionFixation();
$results['session_fixation'] = 'PASS';
} catch (\Exception $e) {
$results['session_fixation'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testEnforcesSessionTimeout();
$results['session_timeout'] = 'PASS';
} catch (\Exception $e) {
$results['session_timeout'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testValidatesSessionDataIntegrity();
$results['data_integrity'] = 'PASS';
} catch (\Exception $e) {
$results['data_integrity'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testSessionCookieSecurityAttributes();
$results['cookie_security'] = 'PASS';
} catch (\Exception $e) {
$results['cookie_security'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testEnforcesConcurrentSessionLimit();
$results['concurrent_sessions'] = 'PASS';
} catch (\Exception $e) {
$results['concurrent_sessions'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testProperSessionDestruction();
$results['session_destruction'] = 'PASS';
} catch (\Exception $e) {
$results['session_destruction'] = 'FAIL: ' . $e->getMessage();
}
return $results;
}
private function detectSessionHijack(
string $sessionIp,
string $requestIp,
string $sessionUserAgent,
string $requestUserAgent
): bool {
// IP mismatch detection
if ($sessionIp !== $requestIp) {
return true;
}
// User-Agent mismatch detection
if ($sessionUserAgent !== $requestUserAgent) {
return true;
}
return false;
}
private function regenerateSessionOnLogin(): void
{
session_regenerate_id(true);
}
private function checkSessionTimeout(int $lastActivity, int $timeoutSeconds): bool
{
$inactiveTime = time() - $lastActivity;
return $inactiveTime > $timeoutSeconds;
}
private function detectSessionTampering(array $originalData, array $currentData): bool
{
// Simplified tampering detection
foreach ($originalData as $key => $value) {
if (!isset($currentData[$key]) || $currentData[$key] !== $value) {
return true;
}
}
return false;
}
private function checkConcurrentSessionLimit(
int $userId,
array $activeSessions,
int $maxSessions
): bool {
return count($activeSessions) < $maxSessions;
}
private function destroySession(): void
{
$_SESSION = [];
session_destroy();
}
}

View File

@@ -0,0 +1,372 @@
<?php
declare(strict_types=1);
namespace Tests\Security\AuthenticationTests;
use Tests\Security\SecurityTestCase;
/**
* Token Validation tests
*
* Tests JWT/Bearer token validation, expiration, and signature verification
*/
final readonly class TokenValidationTest extends SecurityTestCase
{
/**
* Test JWT token structure validation
*/
public function testValidatesJwtStructure(): void
{
$validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
if (!$this->hasValidJwtStructure($validToken)) {
throw new \RuntimeException('Valid JWT structure rejected');
}
echo "✅ Valid JWT structure accepted\n";
// Test invalid structures
$invalidTokens = [
'invalid', // Not base64
'header.payload', // Missing signature
'header.payload.signature.extra', // Too many parts
'', // Empty
];
foreach ($invalidTokens as $token) {
if ($this->hasValidJwtStructure($token)) {
throw new \RuntimeException("Invalid JWT structure accepted: {$token}");
}
}
echo "✅ Invalid JWT structures rejected\n";
}
/**
* Test token expiration validation
*/
public function testValidatesTokenExpiration(): void
{
// Expired token (exp claim in the past)
$expiredToken = [
'sub' => '1234567890',
'name' => 'John Doe',
'exp' => time() - 3600 // Expired 1 hour ago
];
if (!$this->isTokenExpired($expiredToken)) {
throw new \RuntimeException('Expired token not detected');
}
echo "✅ Expired token detected\n";
// Valid token (exp claim in the future)
$validToken = [
'sub' => '1234567890',
'name' => 'John Doe',
'exp' => time() + 3600 // Expires in 1 hour
];
if ($this->isTokenExpired($validToken)) {
throw new \RuntimeException('Valid token marked as expired');
}
echo "✅ Valid token accepted\n";
}
/**
* Test token signature verification
*/
public function testVerifiesTokenSignature(): void
{
$secret = 'test-secret-key';
$header = base64_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = base64_encode(json_encode(['sub' => '123', 'exp' => time() + 3600]));
// Valid signature
$validSignature = base64_encode(hash_hmac('sha256', "$header.$payload", $secret, true));
$validToken = "$header.$payload.$validSignature";
if (!$this->verifyTokenSignature($validToken, $secret)) {
throw new \RuntimeException('Valid token signature rejected');
}
echo "✅ Valid token signature verified\n";
// Invalid signature
$invalidSignature = base64_encode('invalid-signature');
$invalidToken = "$header.$payload.$invalidSignature";
if ($this->verifyTokenSignature($invalidToken, $secret)) {
throw new \RuntimeException('Invalid token signature accepted');
}
echo "✅ Invalid token signature rejected\n";
}
/**
* Test Bearer token format
*/
public function testValidatesBearerTokenFormat(): void
{
$validBearerTokens = [
'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature',
'Bearer valid-api-key-12345',
];
foreach ($validBearerTokens as $token) {
if (!$this->hasValidBearerFormat($token)) {
throw new \RuntimeException("Valid Bearer token rejected: {$token}");
}
}
echo "✅ Valid Bearer token formats accepted\n";
$invalidBearerTokens = [
'bearer token', // Lowercase 'bearer'
'Token xyz', // Wrong prefix
'eyJhbGci...', // Missing 'Bearer' prefix
'', // Empty
];
foreach ($invalidBearerTokens as $token) {
if ($this->hasValidBearerFormat($token)) {
throw new \RuntimeException("Invalid Bearer token accepted: {$token}");
}
}
echo "✅ Invalid Bearer token formats rejected\n";
}
/**
* Test token claims validation
*/
public function testValidatesTokenClaims(): void
{
// Valid claims
$validClaims = [
'sub' => '1234567890',
'name' => 'John Doe',
'iat' => time(),
'exp' => time() + 3600,
'iss' => 'https://example.com',
'aud' => 'https://api.example.com'
];
$validationErrors = $this->validateTokenClaims($validClaims);
if (!empty($validationErrors)) {
throw new \RuntimeException(
"Valid claims rejected: " . implode(', ', $validationErrors)
);
}
echo "✅ Valid token claims accepted\n";
// Missing required claims
$invalidClaims = [
'name' => 'John Doe',
// Missing 'sub' and 'exp'
];
$validationErrors = $this->validateTokenClaims($invalidClaims);
if (empty($validationErrors)) {
throw new \RuntimeException('Missing required claims not detected');
}
echo "✅ Missing required claims detected\n";
}
/**
* Test token issued at (iat) validation
*/
public function testValidatesIssuedAtClaim(): void
{
// Future iat (token issued in the future)
$futureIat = [
'sub' => '123',
'iat' => time() + 3600, // Issued 1 hour in the future
'exp' => time() + 7200
];
if (!$this->isIssuedInFuture($futureIat)) {
throw new \RuntimeException('Future iat not detected');
}
echo "✅ Future iat claim detected\n";
// Valid iat (issued in the past)
$validIat = [
'sub' => '123',
'iat' => time() - 60, // Issued 1 minute ago
'exp' => time() + 3600
];
if ($this->isIssuedInFuture($validIat)) {
throw new \RuntimeException('Valid iat marked as future');
}
echo "✅ Valid iat claim accepted\n";
}
/**
* Test token not before (nbf) validation
*/
public function testValidatesNotBeforeClaim(): void
{
// Token not yet valid (nbf in the future)
$notYetValid = [
'sub' => '123',
'nbf' => time() + 3600, // Valid in 1 hour
'exp' => time() + 7200
];
if (!$this->isNotYetValid($notYetValid)) {
throw new \RuntimeException('Not-yet-valid token not detected');
}
echo "✅ Not-yet-valid token detected\n";
// Token already valid (nbf in the past)
$alreadyValid = [
'sub' => '123',
'nbf' => time() - 60, // Valid since 1 minute ago
'exp' => time() + 3600
];
if ($this->isNotYetValid($alreadyValid)) {
throw new \RuntimeException('Valid token marked as not-yet-valid');
}
echo "✅ Valid token accepted (nbf check)\n";
}
/**
* Run all token validation tests
*/
public function runAllTests(): array
{
$results = [];
try {
$this->testValidatesJwtStructure();
$results['jwt_structure'] = 'PASS';
} catch (\Exception $e) {
$results['jwt_structure'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testValidatesTokenExpiration();
$results['token_expiration'] = 'PASS';
} catch (\Exception $e) {
$results['token_expiration'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testVerifiesTokenSignature();
$results['signature_verification'] = 'PASS';
} catch (\Exception $e) {
$results['signature_verification'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testValidatesBearerTokenFormat();
$results['bearer_format'] = 'PASS';
} catch (\Exception $e) {
$results['bearer_format'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testValidatesTokenClaims();
$results['claims_validation'] = 'PASS';
} catch (\Exception $e) {
$results['claims_validation'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testValidatesIssuedAtClaim();
$results['iat_validation'] = 'PASS';
} catch (\Exception $e) {
$results['iat_validation'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testValidatesNotBeforeClaim();
$results['nbf_validation'] = 'PASS';
} catch (\Exception $e) {
$results['nbf_validation'] = 'FAIL: ' . $e->getMessage();
}
return $results;
}
private function hasValidJwtStructure(string $token): bool
{
$parts = explode('.', $token);
return count($parts) === 3 && !empty($parts[0]) && !empty($parts[1]) && !empty($parts[2]);
}
private function isTokenExpired(array $claims): bool
{
if (!isset($claims['exp'])) {
return true; // No expiration claim = treat as expired
}
return time() >= $claims['exp'];
}
private function verifyTokenSignature(string $token, string $secret): bool
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return false;
}
[$header, $payload, $signature] = $parts;
$validSignature = base64_encode(hash_hmac('sha256', "$header.$payload", $secret, true));
return hash_equals($validSignature, $signature);
}
private function hasValidBearerFormat(string $authHeader): bool
{
return str_starts_with($authHeader, 'Bearer ') && strlen($authHeader) > 7;
}
private function validateTokenClaims(array $claims): array
{
$errors = [];
// Required claims
$requiredClaims = ['sub', 'exp'];
foreach ($requiredClaims as $claim) {
if (!isset($claims[$claim])) {
$errors[] = "Missing required claim: {$claim}";
}
}
return $errors;
}
private function isIssuedInFuture(array $claims): bool
{
if (!isset($claims['iat'])) {
return false;
}
return $claims['iat'] > time();
}
private function isNotYetValid(array $claims): bool
{
if (!isset($claims['nbf'])) {
return false;
}
return time() < $claims['nbf'];
}
}