Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
- Remove middleware reference from Gitea Traefik labels (caused routing issues) - Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s) - Add explicit service reference in Traefik labels - Fix intermittent 504 timeouts by improving PostgreSQL connection handling Fixes Gitea unreachability via git.michaelschiemer.de
339 lines
13 KiB
PHP
339 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\Mfa\MfaService;
|
|
use App\Framework\Mfa\MfaSecretFactory;
|
|
use App\Framework\Mfa\Providers\TotpProvider;
|
|
use App\Framework\Mfa\ValueObjects\MfaChallenge;
|
|
use App\Framework\Mfa\ValueObjects\MfaCode;
|
|
use App\Framework\Mfa\ValueObjects\MfaMethod;
|
|
use App\Framework\Mfa\ValueObjects\MfaSecret;
|
|
use App\Framework\Random\SecureRandomGenerator;
|
|
|
|
beforeEach(function () {
|
|
$this->randomGenerator = new SecureRandomGenerator();
|
|
$this->secretFactory = new MfaSecretFactory($this->randomGenerator);
|
|
$this->totpProvider = new TotpProvider(timeStep: 30, digits: 6, windowSize: 1);
|
|
$this->mfaService = new MfaService($this->totpProvider);
|
|
});
|
|
|
|
describe('MfaSecretFactory', function () {
|
|
it('generates valid base32 secret', function () {
|
|
$secret = $this->secretFactory->generate();
|
|
|
|
expect($secret)->toBeInstanceOf(MfaSecret::class);
|
|
expect($secret->value)->toMatch('/^[A-Z2-7]+$/');
|
|
expect(strlen($secret->value))->toBeGreaterThanOrEqual(32);
|
|
});
|
|
|
|
it('generates secret with custom length', function () {
|
|
$secret = $this->secretFactory->generateWithLength(32);
|
|
|
|
// 32 bytes = 51-52 base32 characters (due to padding)
|
|
expect(strlen($secret->value))->toBeGreaterThanOrEqual(51);
|
|
});
|
|
|
|
it('throws exception for insufficient byte length', function () {
|
|
$this->secretFactory->generateWithLength(10);
|
|
})->throws(InvalidArgumentException::class, 'at least 16 bytes');
|
|
});
|
|
|
|
describe('MfaSecret', function () {
|
|
it('validates base32 format', function () {
|
|
$validSecret = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'; // Valid base32 (32+ chars)
|
|
$secret = MfaSecret::fromString($validSecret);
|
|
|
|
expect($secret->value)->toBe($validSecret);
|
|
});
|
|
|
|
it('throws exception for invalid base32 characters', function () {
|
|
MfaSecret::fromString('INVALID189'); // 1, 8, 9 not valid in base32
|
|
})->throws(InvalidArgumentException::class, 'base32 encoded');
|
|
|
|
it('throws exception for too short secret', function () {
|
|
MfaSecret::fromString('JBSWY3DP'); // Only 8 characters
|
|
})->throws(InvalidArgumentException::class, 'at least 32 characters');
|
|
|
|
it('generates QR code URI', function () {
|
|
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
|
$uri = $secret->toQrCodeUri('MyApp', 'user@example.com');
|
|
|
|
expect($uri)->toStartWith('otpauth://totp/');
|
|
expect($uri)->toContain('MyApp:user%40example.com'); // URL-encoded email
|
|
expect($uri)->toContain('secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
|
expect($uri)->toContain('issuer=MyApp');
|
|
});
|
|
|
|
it('masks secret for safe logging', function () {
|
|
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
|
$masked = $secret->getMasked();
|
|
|
|
expect($masked)->toBe('JBSW****3PXP');
|
|
expect($masked)->not->toContain('EHPK3PXPJBSWY3DPEHPK');
|
|
});
|
|
});
|
|
|
|
describe('MfaChallenge', function () {
|
|
it('creates valid challenge', function () {
|
|
$challenge = MfaChallenge::create(
|
|
challengeId: 'test-123',
|
|
method: MfaMethod::TOTP,
|
|
validitySeconds: 300,
|
|
maxAttempts: 3
|
|
);
|
|
|
|
expect($challenge->challengeId)->toBe('test-123');
|
|
expect($challenge->method)->toBe(MfaMethod::TOTP);
|
|
expect($challenge->attempts)->toBe(0);
|
|
expect($challenge->maxAttempts)->toBe(3);
|
|
expect($challenge->isValid())->toBeTrue();
|
|
});
|
|
|
|
it('detects expiration', function () {
|
|
$now = new DateTimeImmutable();
|
|
$created = $now->modify('-10 seconds');
|
|
$expired = $now->modify('-5 seconds'); // Expired 5 seconds ago
|
|
|
|
$challenge = new MfaChallenge(
|
|
challengeId: 'test-123',
|
|
method: MfaMethod::TOTP,
|
|
createdAt: $created,
|
|
expiresAt: $expired,
|
|
attempts: 0,
|
|
maxAttempts: 3
|
|
);
|
|
|
|
expect($challenge->isExpired())->toBeTrue();
|
|
expect($challenge->isValid())->toBeFalse();
|
|
});
|
|
|
|
it('tracks attempts correctly', function () {
|
|
$challenge = MfaChallenge::create('test-123', MfaMethod::TOTP, maxAttempts: 3);
|
|
|
|
expect($challenge->attempts)->toBe(0);
|
|
expect($challenge->hasAttemptsRemaining())->toBeTrue();
|
|
expect($challenge->getRemainingAttempts())->toBe(3);
|
|
|
|
$challenge = $challenge->withIncrementedAttempts();
|
|
expect($challenge->attempts)->toBe(1);
|
|
expect($challenge->getRemainingAttempts())->toBe(2);
|
|
|
|
$challenge = $challenge->withIncrementedAttempts();
|
|
$challenge = $challenge->withIncrementedAttempts();
|
|
expect($challenge->attempts)->toBe(3);
|
|
expect($challenge->hasAttemptsRemaining())->toBeFalse();
|
|
expect($challenge->isValid())->toBeFalse();
|
|
});
|
|
|
|
it('exports to array correctly', function () {
|
|
$challenge = MfaChallenge::create('test-123', MfaMethod::TOTP, validitySeconds: 300);
|
|
$array = $challenge->toArray();
|
|
|
|
expect($array)->toHaveKey('challenge_id');
|
|
expect($array)->toHaveKey('method');
|
|
expect($array)->toHaveKey('method_display');
|
|
expect($array)->toHaveKey('remaining_attempts');
|
|
expect($array)->toHaveKey('seconds_until_expiry');
|
|
expect($array)->toHaveKey('is_valid');
|
|
|
|
expect($array['challenge_id'])->toBe('test-123');
|
|
expect($array['method'])->toBe('totp');
|
|
expect($array['is_valid'])->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('TotpProvider', function () {
|
|
it('generates challenge for TOTP', function () {
|
|
$challenge = $this->totpProvider->generateChallenge();
|
|
|
|
expect($challenge)->toBeInstanceOf(MfaChallenge::class);
|
|
expect($challenge->method)->toBe(MfaMethod::TOTP);
|
|
expect($challenge->isValid())->toBeTrue();
|
|
});
|
|
|
|
it('verifies valid TOTP code', function () {
|
|
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
|
$challenge = $this->totpProvider->generateChallenge();
|
|
|
|
// Generate code for current time window
|
|
$currentTime = time();
|
|
$timeStep = (int) floor($currentTime / 30);
|
|
|
|
// We'll use the provider's internal logic to generate a valid code
|
|
// In real usage, the authenticator app would generate this
|
|
$reflector = new ReflectionClass($this->totpProvider);
|
|
$method = $reflector->getMethod('generateCodeForTimeStep');
|
|
$method->setAccessible(true);
|
|
$expectedCode = $method->invoke($this->totpProvider, $secret, $timeStep);
|
|
|
|
$code = MfaCode::fromString(str_pad((string) $expectedCode, 6, '0', STR_PAD_LEFT));
|
|
|
|
$result = $this->totpProvider->verify($challenge, $code, ['secret' => $secret]);
|
|
|
|
expect($result)->toBeTrue();
|
|
});
|
|
|
|
it('rejects invalid TOTP code', function () {
|
|
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
|
$challenge = $this->totpProvider->generateChallenge();
|
|
$wrongCode = MfaCode::fromString('000000');
|
|
|
|
$result = $this->totpProvider->verify($challenge, $wrongCode, ['secret' => $secret]);
|
|
|
|
expect($result)->toBeFalse();
|
|
});
|
|
|
|
it('accepts code within time window', function () {
|
|
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
|
$challenge = $this->totpProvider->generateChallenge();
|
|
|
|
$currentTime = time();
|
|
$previousTimeStep = (int) floor($currentTime / 30) - 1; // One window before
|
|
|
|
$reflector = new ReflectionClass($this->totpProvider);
|
|
$method = $reflector->getMethod('generateCodeForTimeStep');
|
|
$method->setAccessible(true);
|
|
$expectedCode = $method->invoke($this->totpProvider, $secret, $previousTimeStep);
|
|
|
|
$code = MfaCode::fromString(str_pad((string) $expectedCode, 6, '0', STR_PAD_LEFT));
|
|
|
|
// Should accept code from previous window (windowSize = 1)
|
|
$result = $this->totpProvider->verify($challenge, $code, ['secret' => $secret]);
|
|
|
|
expect($result)->toBeTrue();
|
|
});
|
|
|
|
it('throws exception when secret is missing', function () {
|
|
$challenge = $this->totpProvider->generateChallenge();
|
|
$code = MfaCode::fromString('123456');
|
|
|
|
$this->totpProvider->verify($challenge, $code, []);
|
|
})->throws(InvalidArgumentException::class, 'requires secret');
|
|
|
|
it('indicates TOTP does not require server-side code generation', function () {
|
|
expect($this->totpProvider->requiresCodeGeneration())->toBeFalse();
|
|
});
|
|
|
|
it('returns correct code validity period', function () {
|
|
expect($this->totpProvider->getCodeValiditySeconds())->toBe(30);
|
|
});
|
|
});
|
|
|
|
describe('MfaService', function () {
|
|
it('generates challenge via service', function () {
|
|
$challenge = $this->mfaService->generateChallenge(MfaMethod::TOTP);
|
|
|
|
expect($challenge)->toBeInstanceOf(MfaChallenge::class);
|
|
expect($challenge->method)->toBe(MfaMethod::TOTP);
|
|
});
|
|
|
|
it('verifies code via service', function () {
|
|
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
|
$challenge = $this->mfaService->generateChallenge(MfaMethod::TOTP);
|
|
|
|
$currentTime = time();
|
|
$timeStep = (int) floor($currentTime / 30);
|
|
|
|
$reflector = new ReflectionClass($this->totpProvider);
|
|
$method = $reflector->getMethod('generateCodeForTimeStep');
|
|
$method->setAccessible(true);
|
|
$expectedCode = $method->invoke($this->totpProvider, $secret, $timeStep);
|
|
|
|
$code = MfaCode::fromString(str_pad((string) $expectedCode, 6, '0', STR_PAD_LEFT));
|
|
|
|
$result = $this->mfaService->verify($challenge, $code, ['secret' => $secret]);
|
|
|
|
expect($result)->toBeTrue();
|
|
});
|
|
|
|
it('rejects verification for expired challenge', function () {
|
|
$now = new DateTimeImmutable();
|
|
$created = $now->modify('-10 seconds');
|
|
$expired = $now->modify('-5 seconds'); // Expired 5 seconds ago
|
|
|
|
$challenge = new MfaChallenge(
|
|
challengeId: 'test-123',
|
|
method: MfaMethod::TOTP,
|
|
createdAt: $created,
|
|
expiresAt: $expired,
|
|
attempts: 0,
|
|
maxAttempts: 3
|
|
);
|
|
|
|
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
|
$code = MfaCode::fromString('123456');
|
|
|
|
$result = $this->mfaService->verify($challenge, $code, ['secret' => $secret]);
|
|
|
|
expect($result)->toBeFalse();
|
|
});
|
|
|
|
it('checks if method is supported', function () {
|
|
expect($this->mfaService->supportsMethod(MfaMethod::TOTP))->toBeTrue();
|
|
expect($this->mfaService->supportsMethod(MfaMethod::SMS))->toBeFalse();
|
|
});
|
|
|
|
it('returns supported methods', function () {
|
|
$methods = $this->mfaService->getSupportedMethods();
|
|
|
|
expect($methods)->toHaveCount(1);
|
|
expect($methods[0])->toBe(MfaMethod::TOTP);
|
|
});
|
|
|
|
it('throws exception for unsupported method', function () {
|
|
$this->mfaService->generateChallenge(MfaMethod::SMS);
|
|
})->throws(App\Framework\Mfa\Exceptions\MfaException::class, 'No MFA provider registered');
|
|
});
|
|
|
|
describe('TOTP RFC 6238 Compliance', function () {
|
|
it('generates 6-digit codes by default', function () {
|
|
$provider = new TotpProvider();
|
|
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
|
|
|
$reflector = new ReflectionClass($provider);
|
|
$method = $reflector->getMethod('generateCodeForTimeStep');
|
|
$method->setAccessible(true);
|
|
|
|
$code = $method->invoke($provider, $secret, 12345);
|
|
$codeStr = str_pad((string) $code, 6, '0', STR_PAD_LEFT);
|
|
|
|
expect(strlen($codeStr))->toBe(6);
|
|
expect($codeStr)->toMatch('/^\d{6}$/');
|
|
});
|
|
|
|
it('supports 8-digit codes', function () {
|
|
$provider = new TotpProvider(digits: 8);
|
|
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
|
|
|
|
$reflector = new ReflectionClass($provider);
|
|
$method = $reflector->getMethod('generateCodeForTimeStep');
|
|
$method->setAccessible(true);
|
|
|
|
$code = $method->invoke($provider, $secret, 12345);
|
|
$codeStr = str_pad((string) $code, 8, '0', STR_PAD_LEFT);
|
|
|
|
expect(strlen($codeStr))->toBe(8);
|
|
});
|
|
|
|
it('uses 30-second time step by default', function () {
|
|
$provider = new TotpProvider();
|
|
|
|
expect($provider->getCodeValiditySeconds())->toBe(30);
|
|
});
|
|
|
|
it('validates constructor parameters', function () {
|
|
expect(fn() => new TotpProvider(timeStep: 0))
|
|
->toThrow(InvalidArgumentException::class, 'at least 1 second');
|
|
|
|
expect(fn() => new TotpProvider(digits: 5))
|
|
->toThrow(InvalidArgumentException::class, 'between 6 and 8');
|
|
|
|
expect(fn() => new TotpProvider(digits: 9))
|
|
->toThrow(InvalidArgumentException::class, 'between 6 and 8');
|
|
|
|
expect(fn() => new TotpProvider(windowSize: -1))
|
|
->toThrow(InvalidArgumentException::class, 'cannot be negative');
|
|
});
|
|
});
|