- 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.
373 lines
11 KiB
PHP
373 lines
11 KiB
PHP
<?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'];
|
|
}
|
|
}
|