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

- 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:
2025-11-09 14:46:15 +01:00
parent 85c369e846
commit 36ef2a1e2c
1366 changed files with 104925 additions and 28719 deletions

View 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
{
}

View 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
);
}
}

View 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;
}

View 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);
}
}

View 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];
}
}

View 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;
}
}

View 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(),
];
}
}

View 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;
}
}

View 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,
};
}
}

View 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;
}
}