fix: Gitea Traefik routing and connection pool optimization
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
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
This commit is contained in:
14
src/Framework/Mfa/Exceptions/MfaException.php
Normal file
14
src/Framework/Mfa/Exceptions/MfaException.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Mfa\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Base exception for MFA-related errors
|
||||
*/
|
||||
class MfaException extends RuntimeException
|
||||
{
|
||||
}
|
||||
27
src/Framework/Mfa/MfaInitializer.php
Normal file
27
src/Framework/Mfa/MfaInitializer.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Mfa;
|
||||
|
||||
use App\Framework\Attributes\Initializer;
|
||||
use App\Framework\Mfa\Providers\TotpProvider;
|
||||
|
||||
/**
|
||||
* DI Container initializer for MFA module
|
||||
*
|
||||
* Registers TotpProvider with specific configuration.
|
||||
* Other MFA services are auto-resolved by the container.
|
||||
*/
|
||||
final readonly class MfaInitializer
|
||||
{
|
||||
#[Initializer]
|
||||
public function initializeTotpProvider(): TotpProvider
|
||||
{
|
||||
return new TotpProvider(
|
||||
timeStep: 30, // 30-second time windows
|
||||
digits: 6, // 6-digit codes
|
||||
windowSize: 1 // Allow 1 window before/after for clock skew
|
||||
);
|
||||
}
|
||||
}
|
||||
59
src/Framework/Mfa/MfaProvider.php
Normal file
59
src/Framework/Mfa/MfaProvider.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Mfa;
|
||||
|
||||
use App\Framework\Mfa\ValueObjects\MfaChallenge;
|
||||
use App\Framework\Mfa\ValueObjects\MfaCode;
|
||||
use App\Framework\Mfa\ValueObjects\MfaMethod;
|
||||
|
||||
/**
|
||||
* Interface for MFA providers (TOTP, SMS, Email, Backup Codes)
|
||||
*
|
||||
* Each MFA method implements this interface to provide:
|
||||
* - Challenge generation (send code, setup TOTP, etc.)
|
||||
* - Code verification
|
||||
* - Method-specific configuration
|
||||
*/
|
||||
interface MfaProvider
|
||||
{
|
||||
/**
|
||||
* Get the MFA method this provider handles
|
||||
*/
|
||||
public function getMethod(): MfaMethod;
|
||||
|
||||
/**
|
||||
* Generate a new MFA challenge
|
||||
*
|
||||
* For TOTP: No-op (code generated client-side)
|
||||
* For SMS/Email: Send verification code
|
||||
* For Backup: Retrieve next unused code
|
||||
*
|
||||
* @param array $context Provider-specific context (e.g., phone number, email)
|
||||
* @return MfaChallenge The challenge to be verified
|
||||
*/
|
||||
public function generateChallenge(array $context = []): MfaChallenge;
|
||||
|
||||
/**
|
||||
* Verify an MFA code against a challenge
|
||||
*
|
||||
* @param MfaChallenge $challenge The active challenge
|
||||
* @param MfaCode $code The code to verify
|
||||
* @param array $context Provider-specific context
|
||||
* @return bool True if code is valid
|
||||
*/
|
||||
public function verify(MfaChallenge $challenge, MfaCode $code, array $context = []): bool;
|
||||
|
||||
/**
|
||||
* Check if this provider requires code generation
|
||||
* (TOTP and Backup Codes don't, SMS and Email do)
|
||||
*/
|
||||
public function requiresCodeGeneration(): bool;
|
||||
|
||||
/**
|
||||
* Get the validity period for codes generated by this provider
|
||||
* (in seconds, 0 for non-expiring)
|
||||
*/
|
||||
public function getCodeValiditySeconds(): int;
|
||||
}
|
||||
52
src/Framework/Mfa/MfaSecretFactory.php
Normal file
52
src/Framework/Mfa/MfaSecretFactory.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Mfa;
|
||||
|
||||
use App\Framework\Mfa\ValueObjects\MfaSecret;
|
||||
use App\Framework\Random\RandomGenerator;
|
||||
|
||||
/**
|
||||
* Factory for generating cryptographically secure MFA secrets
|
||||
*
|
||||
* Uses framework's RandomGenerator for consistent random generation
|
||||
* across the codebase. TOTP secrets are 160 bits (20 bytes).
|
||||
*/
|
||||
final readonly class MfaSecretFactory
|
||||
{
|
||||
public function __construct(
|
||||
private RandomGenerator $randomGenerator
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate a new TOTP secret (160 bits / 20 bytes / 32 base32 chars)
|
||||
*/
|
||||
public function generate(): MfaSecret
|
||||
{
|
||||
// Generate 20 random bytes (160 bits) - standard for TOTP
|
||||
$randomBytes = $this->randomGenerator->bytes(20);
|
||||
|
||||
// Encode as base32 for TOTP compatibility
|
||||
$base32Secret = MfaSecret::base32Encode($randomBytes);
|
||||
|
||||
return MfaSecret::fromString($base32Secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secret with custom length
|
||||
*
|
||||
* @param int $byteLength Number of random bytes (default: 20 for 160 bits)
|
||||
*/
|
||||
public function generateWithLength(int $byteLength): MfaSecret
|
||||
{
|
||||
if ($byteLength < 16) {
|
||||
throw new \InvalidArgumentException('MFA secret must be at least 16 bytes (128 bits)');
|
||||
}
|
||||
|
||||
$randomBytes = $this->randomGenerator->bytes($byteLength);
|
||||
$base32Secret = MfaSecret::base32Encode($randomBytes);
|
||||
|
||||
return MfaSecret::fromString($base32Secret);
|
||||
}
|
||||
}
|
||||
118
src/Framework/Mfa/MfaService.php
Normal file
118
src/Framework/Mfa/MfaService.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Mfa;
|
||||
|
||||
use App\Framework\Mfa\Exceptions\MfaException;
|
||||
use App\Framework\Mfa\ValueObjects\MfaChallenge;
|
||||
use App\Framework\Mfa\ValueObjects\MfaCode;
|
||||
use App\Framework\Mfa\ValueObjects\MfaMethod;
|
||||
|
||||
/**
|
||||
* Main MFA service that coordinates providers
|
||||
*
|
||||
* Provides a unified interface for MFA operations across all methods:
|
||||
* - Generate challenges (send codes, setup TOTP)
|
||||
* - Verify codes
|
||||
* - Provider management
|
||||
*/
|
||||
final readonly class MfaService
|
||||
{
|
||||
/** @var array<string, MfaProvider> */
|
||||
private array $providers;
|
||||
|
||||
public function __construct(MfaProvider ...$providers)
|
||||
{
|
||||
$mapped = [];
|
||||
foreach ($providers as $provider) {
|
||||
$mapped[$provider->getMethod()->value] = $provider;
|
||||
}
|
||||
$this->providers = $mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an MFA challenge for a specific method
|
||||
*
|
||||
* @param MfaMethod $method The MFA method to use
|
||||
* @param array $context Method-specific context (phone, email, secret, etc.)
|
||||
* @return MfaChallenge The generated challenge
|
||||
* @throws MfaException If provider not found or challenge generation fails
|
||||
*/
|
||||
public function generateChallenge(MfaMethod $method, array $context = []): MfaChallenge
|
||||
{
|
||||
$provider = $this->getProvider($method);
|
||||
|
||||
try {
|
||||
return $provider->generateChallenge($context);
|
||||
} catch (\Throwable $e) {
|
||||
throw new MfaException(
|
||||
"Failed to generate MFA challenge for method {$method->value}: {$e->getMessage()}",
|
||||
previous: $e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an MFA code against a challenge
|
||||
*
|
||||
* @param MfaChallenge $challenge The challenge to verify against
|
||||
* @param MfaCode $code The code to verify
|
||||
* @param array $context Method-specific context (secret, etc.)
|
||||
* @return bool True if code is valid
|
||||
* @throws MfaException If verification fails unexpectedly
|
||||
*/
|
||||
public function verify(MfaChallenge $challenge, MfaCode $code, array $context = []): bool
|
||||
{
|
||||
// Check if challenge is still valid
|
||||
if (!$challenge->isValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$provider = $this->getProvider($challenge->method);
|
||||
|
||||
try {
|
||||
return $provider->verify($challenge, $code, $context);
|
||||
} catch (\Throwable $e) {
|
||||
throw new MfaException(
|
||||
"Failed to verify MFA code for method {$challenge->method->value}: {$e->getMessage()}",
|
||||
previous: $e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific MFA method is supported
|
||||
*/
|
||||
public function supportsMethod(MfaMethod $method): bool
|
||||
{
|
||||
return isset($this->providers[$method->value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all supported MFA methods
|
||||
*
|
||||
* @return MfaMethod[]
|
||||
*/
|
||||
public function getSupportedMethods(): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
fn(MfaProvider $provider) => $provider->getMethod(),
|
||||
$this->providers
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider for a specific method
|
||||
*
|
||||
* @throws MfaException If provider not found
|
||||
*/
|
||||
private function getProvider(MfaMethod $method): MfaProvider
|
||||
{
|
||||
if (!isset($this->providers[$method->value])) {
|
||||
throw new MfaException("No MFA provider registered for method: {$method->value}");
|
||||
}
|
||||
|
||||
return $this->providers[$method->value];
|
||||
}
|
||||
}
|
||||
167
src/Framework/Mfa/Providers/TotpProvider.php
Normal file
167
src/Framework/Mfa/Providers/TotpProvider.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Mfa\Providers;
|
||||
|
||||
use App\Framework\Mfa\MfaProvider;
|
||||
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 InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Time-based One-Time Password (TOTP) provider
|
||||
*
|
||||
* Implements RFC 6238 TOTP algorithm for authenticator apps
|
||||
* (Google Authenticator, Authy, 1Password, etc.)
|
||||
*/
|
||||
final readonly class TotpProvider implements MfaProvider
|
||||
{
|
||||
/**
|
||||
* @param int $timeStep Time step in seconds (default: 30)
|
||||
* @param int $digits Number of digits in code (default: 6)
|
||||
* @param int $windowSize Number of time steps to check before/after current (default: 1)
|
||||
*/
|
||||
public function __construct(
|
||||
private int $timeStep = 30,
|
||||
private int $digits = 6,
|
||||
private int $windowSize = 1
|
||||
) {
|
||||
if ($timeStep < 1) {
|
||||
throw new InvalidArgumentException('Time step must be at least 1 second');
|
||||
}
|
||||
|
||||
if ($digits < 6 || $digits > 8) {
|
||||
throw new InvalidArgumentException('TOTP digits must be between 6 and 8');
|
||||
}
|
||||
|
||||
if ($windowSize < 0) {
|
||||
throw new InvalidArgumentException('Window size cannot be negative');
|
||||
}
|
||||
}
|
||||
|
||||
public function getMethod(): MfaMethod
|
||||
{
|
||||
return MfaMethod::TOTP;
|
||||
}
|
||||
|
||||
public function generateChallenge(array $context = []): MfaChallenge
|
||||
{
|
||||
// TOTP doesn't need server-side challenge generation
|
||||
// Code is generated client-side by authenticator app
|
||||
// We just create a challenge object for tracking attempts
|
||||
|
||||
$challengeId = bin2hex(random_bytes(16));
|
||||
|
||||
return MfaChallenge::create(
|
||||
challengeId: $challengeId,
|
||||
method: MfaMethod::TOTP,
|
||||
validitySeconds: 300, // 5 minutes to enter the code
|
||||
maxAttempts: 3
|
||||
);
|
||||
}
|
||||
|
||||
public function verify(MfaChallenge $challenge, MfaCode $code, array $context = []): bool
|
||||
{
|
||||
if (!isset($context['secret'])) {
|
||||
throw new InvalidArgumentException('TOTP verification requires secret in context');
|
||||
}
|
||||
|
||||
$secret = $context['secret'];
|
||||
if (!$secret instanceof MfaSecret) {
|
||||
throw new InvalidArgumentException('TOTP secret must be MfaSecret instance');
|
||||
}
|
||||
|
||||
// Check code against current time window and adjacent windows
|
||||
$currentTime = time();
|
||||
$currentTimeStep = (int) floor($currentTime / $this->timeStep);
|
||||
|
||||
for ($i = -$this->windowSize; $i <= $this->windowSize; $i++) {
|
||||
$timeStep = $currentTimeStep + $i;
|
||||
$expectedCode = $this->generateCodeForTimeStep($secret, $timeStep);
|
||||
|
||||
if ($code->equals(MfaCode::fromString((string) $expectedCode))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function requiresCodeGeneration(): bool
|
||||
{
|
||||
return false; // TOTP codes are generated client-side
|
||||
}
|
||||
|
||||
public function getCodeValiditySeconds(): int
|
||||
{
|
||||
return $this->timeStep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate TOTP code for a specific time step
|
||||
*
|
||||
* @param MfaSecret $secret The shared secret
|
||||
* @param int $timeStep The time step to generate code for
|
||||
* @return int The TOTP code
|
||||
*/
|
||||
private function generateCodeForTimeStep(MfaSecret $secret, int $timeStep): int
|
||||
{
|
||||
// Decode base32 secret to binary
|
||||
$binarySecret = $this->base32Decode($secret->value);
|
||||
|
||||
// Convert time step to 8-byte big-endian
|
||||
$timeBytes = pack('N*', 0, $timeStep);
|
||||
|
||||
// HMAC-SHA1
|
||||
$hash = hash_hmac('sha1', $timeBytes, $binarySecret, true);
|
||||
|
||||
// Dynamic truncation (RFC 4226)
|
||||
$offset = ord($hash[19]) & 0xf;
|
||||
$code = (
|
||||
((ord($hash[$offset]) & 0x7f) << 24) |
|
||||
((ord($hash[$offset + 1]) & 0xff) << 16) |
|
||||
((ord($hash[$offset + 2]) & 0xff) << 8) |
|
||||
(ord($hash[$offset + 3]) & 0xff)
|
||||
);
|
||||
|
||||
// Generate N-digit code
|
||||
$code = $code % (10 ** $this->digits);
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base32 string to binary
|
||||
*
|
||||
* @param string $base32 Base32-encoded string
|
||||
* @return string Binary data
|
||||
*/
|
||||
private function base32Decode(string $base32): string
|
||||
{
|
||||
$base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$base32 = strtoupper($base32);
|
||||
$decoded = '';
|
||||
$buffer = 0;
|
||||
$bitsLeft = 0;
|
||||
|
||||
foreach (str_split($base32) as $char) {
|
||||
$value = strpos($base32Chars, $char);
|
||||
if ($value === false) {
|
||||
throw new InvalidArgumentException("Invalid base32 character: {$char}");
|
||||
}
|
||||
|
||||
$buffer = ($buffer << 5) | $value;
|
||||
$bitsLeft += 5;
|
||||
|
||||
if ($bitsLeft >= 8) {
|
||||
$bitsLeft -= 8;
|
||||
$decoded .= chr(($buffer >> $bitsLeft) & 0xFF);
|
||||
}
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
121
src/Framework/Mfa/ValueObjects/MfaChallenge.php
Normal file
121
src/Framework/Mfa/ValueObjects/MfaChallenge.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Mfa\ValueObjects;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Represents an active MFA challenge/verification attempt
|
||||
*
|
||||
* Tracks the state of an MFA verification process, including:
|
||||
* - Which MFA method is being used
|
||||
* - When the challenge was created
|
||||
* - When it expires
|
||||
* - How many attempts have been made
|
||||
*/
|
||||
final readonly class MfaChallenge
|
||||
{
|
||||
public function __construct(
|
||||
public string $challengeId,
|
||||
public MfaMethod $method,
|
||||
public DateTimeImmutable $createdAt,
|
||||
public DateTimeImmutable $expiresAt,
|
||||
public int $attempts = 0,
|
||||
public int $maxAttempts = 3
|
||||
) {
|
||||
if (empty($challengeId)) {
|
||||
throw new InvalidArgumentException('Challenge ID cannot be empty');
|
||||
}
|
||||
|
||||
if ($attempts < 0) {
|
||||
throw new InvalidArgumentException('Attempts cannot be negative');
|
||||
}
|
||||
|
||||
if ($maxAttempts < 1) {
|
||||
throw new InvalidArgumentException('Max attempts must be at least 1');
|
||||
}
|
||||
|
||||
if ($expiresAt <= $createdAt) {
|
||||
throw new InvalidArgumentException('Expiration must be after creation time');
|
||||
}
|
||||
}
|
||||
|
||||
public static function create(
|
||||
string $challengeId,
|
||||
MfaMethod $method,
|
||||
int $validitySeconds = 300,
|
||||
int $maxAttempts = 3
|
||||
): self {
|
||||
$now = new DateTimeImmutable();
|
||||
$expiresAt = $now->modify("+{$validitySeconds} seconds");
|
||||
|
||||
return new self(
|
||||
challengeId: $challengeId,
|
||||
method: $method,
|
||||
createdAt: $now,
|
||||
expiresAt: $expiresAt,
|
||||
attempts: 0,
|
||||
maxAttempts: $maxAttempts
|
||||
);
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return new DateTimeImmutable() >= $this->expiresAt;
|
||||
}
|
||||
|
||||
public function hasAttemptsRemaining(): bool
|
||||
{
|
||||
return $this->attempts < $this->maxAttempts;
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
return !$this->isExpired() && $this->hasAttemptsRemaining();
|
||||
}
|
||||
|
||||
public function withIncrementedAttempts(): self
|
||||
{
|
||||
return new self(
|
||||
challengeId: $this->challengeId,
|
||||
method: $this->method,
|
||||
createdAt: $this->createdAt,
|
||||
expiresAt: $this->expiresAt,
|
||||
attempts: $this->attempts + 1,
|
||||
maxAttempts: $this->maxAttempts
|
||||
);
|
||||
}
|
||||
|
||||
public function getRemainingAttempts(): int
|
||||
{
|
||||
return max(0, $this->maxAttempts - $this->attempts);
|
||||
}
|
||||
|
||||
public function getSecondsUntilExpiry(): int
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$diff = $this->expiresAt->getTimestamp() - $now->getTimestamp();
|
||||
|
||||
return max(0, $diff);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'challenge_id' => $this->challengeId,
|
||||
'method' => $this->method->value,
|
||||
'method_display' => $this->method->getDisplayName(),
|
||||
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
|
||||
'expires_at' => $this->expiresAt->format('Y-m-d H:i:s'),
|
||||
'attempts' => $this->attempts,
|
||||
'max_attempts' => $this->maxAttempts,
|
||||
'remaining_attempts' => $this->getRemainingAttempts(),
|
||||
'seconds_until_expiry' => $this->getSecondsUntilExpiry(),
|
||||
'is_valid' => $this->isValid(),
|
||||
'is_expired' => $this->isExpired(),
|
||||
];
|
||||
}
|
||||
}
|
||||
50
src/Framework/Mfa/ValueObjects/MfaCode.php
Normal file
50
src/Framework/Mfa/ValueObjects/MfaCode.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Mfa\ValueObjects;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* MFA verification code value object
|
||||
*/
|
||||
final readonly class MfaCode
|
||||
{
|
||||
public function __construct(
|
||||
public string $value
|
||||
) {
|
||||
if (empty($value)) {
|
||||
throw new InvalidArgumentException('MFA code cannot be empty');
|
||||
}
|
||||
|
||||
if (!preg_match('/^[0-9]{4,8}$/', $value)) {
|
||||
throw new InvalidArgumentException('MFA code must be 4-8 digits');
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromString(string $value): self
|
||||
{
|
||||
return new self(trim($value));
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return hash_equals($this->value, $other->value);
|
||||
}
|
||||
|
||||
public function length(): int
|
||||
{
|
||||
return strlen($this->value);
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
65
src/Framework/Mfa/ValueObjects/MfaMethod.php
Normal file
65
src/Framework/Mfa/ValueObjects/MfaMethod.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Mfa\ValueObjects;
|
||||
|
||||
/**
|
||||
* Multi-Factor Authentication methods supported by the framework
|
||||
*/
|
||||
enum MfaMethod: string
|
||||
{
|
||||
case TOTP = 'totp'; // Time-based One-Time Password (Google Authenticator, Authy)
|
||||
case SMS = 'sms'; // SMS verification code
|
||||
case EMAIL = 'email'; // Email verification code
|
||||
case BACKUP_CODE = 'backup'; // Pre-generated backup codes
|
||||
|
||||
public function getDisplayName(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::TOTP => 'Authenticator App',
|
||||
self::SMS => 'SMS Code',
|
||||
self::EMAIL => 'Email Code',
|
||||
self::BACKUP_CODE => 'Backup Code',
|
||||
};
|
||||
}
|
||||
|
||||
public function requiresCodeGeneration(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::SMS, self::EMAIL => true,
|
||||
self::TOTP, self::BACKUP_CODE => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function getCodeLength(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::TOTP => 6,
|
||||
self::SMS, self::EMAIL => 6,
|
||||
self::BACKUP_CODE => 8,
|
||||
};
|
||||
}
|
||||
|
||||
public function getCodeValiditySeconds(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::TOTP => 30, // TOTP codes rotate every 30 seconds
|
||||
self::SMS, self::EMAIL => 300, // SMS/Email codes valid for 5 minutes
|
||||
self::BACKUP_CODE => 0, // Backup codes don't expire
|
||||
};
|
||||
}
|
||||
|
||||
public function isTimeBased(): bool
|
||||
{
|
||||
return $this === self::TOTP;
|
||||
}
|
||||
|
||||
public function supportsResend(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::SMS, self::EMAIL => true,
|
||||
self::TOTP, self::BACKUP_CODE => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
114
src/Framework/Mfa/ValueObjects/MfaSecret.php
Normal file
114
src/Framework/Mfa/ValueObjects/MfaSecret.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Mfa\ValueObjects;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* MFA secret for TOTP authentication
|
||||
*
|
||||
* Immutable value object representing a base32-encoded secret
|
||||
* used for Time-based One-Time Password (TOTP) generation.
|
||||
*/
|
||||
final readonly class MfaSecret
|
||||
{
|
||||
public function __construct(
|
||||
public string $value
|
||||
) {
|
||||
if (empty($value)) {
|
||||
throw new InvalidArgumentException('MFA secret cannot be empty');
|
||||
}
|
||||
|
||||
// Validate base32 format (A-Z, 2-7, no padding for simplicity)
|
||||
if (!preg_match('/^[A-Z2-7]+$/', $value)) {
|
||||
throw new InvalidArgumentException('MFA secret must be base32 encoded (A-Z, 2-7)');
|
||||
}
|
||||
|
||||
// TOTP secrets should be at least 128 bits (20 bytes = 32 base32 chars)
|
||||
if (strlen($value) < 32) {
|
||||
throw new InvalidArgumentException('MFA secret must be at least 32 characters (128 bits)');
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromString(string $value): self
|
||||
{
|
||||
return new self(strtoupper(trim($value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the secret as a QR code URI for authenticator apps
|
||||
*
|
||||
* @param string $issuer The service name (e.g., "MyApp")
|
||||
* @param string $accountName The user's account identifier (e.g., email)
|
||||
*/
|
||||
public function toQrCodeUri(string $issuer, string $accountName): string
|
||||
{
|
||||
$encodedIssuer = rawurlencode($issuer);
|
||||
$encodedAccount = rawurlencode($accountName);
|
||||
|
||||
return sprintf(
|
||||
'otpauth://totp/%s:%s?secret=%s&issuer=%s',
|
||||
$encodedIssuer,
|
||||
$encodedAccount,
|
||||
$this->value,
|
||||
$encodedIssuer
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a masked version of the secret for logging/display
|
||||
* Shows first 4 and last 4 characters only
|
||||
*/
|
||||
public function getMasked(): string
|
||||
{
|
||||
if (strlen($this->value) <= 8) {
|
||||
return '****';
|
||||
}
|
||||
|
||||
return substr($this->value, 0, 4) . '****' . substr($this->value, -4);
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base32 encode binary data (RFC 4648)
|
||||
*
|
||||
* @internal Used by MfaSecretFactory
|
||||
*/
|
||||
public static function base32Encode(string $data): string
|
||||
{
|
||||
$base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$encoded = '';
|
||||
$buffer = 0;
|
||||
$bitsLeft = 0;
|
||||
|
||||
foreach (str_split($data) as $byte) {
|
||||
$buffer = ($buffer << 8) | ord($byte);
|
||||
$bitsLeft += 8;
|
||||
|
||||
while ($bitsLeft >= 5) {
|
||||
$bitsLeft -= 5;
|
||||
$index = ($buffer >> $bitsLeft) & 0x1F;
|
||||
$encoded .= $base32Chars[$index];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle remaining bits
|
||||
if ($bitsLeft > 0) {
|
||||
$index = ($buffer << (5 - $bitsLeft)) & 0x1F;
|
||||
$encoded .= $base32Chars[$index];
|
||||
}
|
||||
|
||||
return $encoded;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user