fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled
Some checks failed
Deploy Application / deploy (push) Has been cancelled
This commit is contained in:
@@ -6,6 +6,8 @@ namespace App\Framework\Http\Session;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Logging\ValueObjects\LogContext;
|
||||
use App\Framework\Security\CsrfToken;
|
||||
use App\Framework\Security\CsrfTokenData;
|
||||
use App\Framework\Security\CsrfTokenGenerator;
|
||||
@@ -13,48 +15,103 @@ use App\Framework\Security\CsrfTokenGenerator;
|
||||
final readonly class CsrfProtection
|
||||
{
|
||||
private const int TOKEN_LIFETIME = 7200; // 2h - Better UX for long forms
|
||||
private const int MAX_TOKENS_PER_FORM = 3;
|
||||
private const int RE_SUBMIT_WINDOW = 30; // 30 seconds for re-submits
|
||||
private const int USED_TOKEN_CLEANUP_TIME = 300; // 5 minutes
|
||||
|
||||
private Session $session;
|
||||
private ?SessionManager $sessionManager;
|
||||
|
||||
public function __construct(
|
||||
Session $session,
|
||||
private CsrfTokenGenerator $tokenGenerator,
|
||||
private Clock $clock
|
||||
private Clock $clock,
|
||||
private Logger $logger,
|
||||
private Environment $environment,
|
||||
?SessionManager $sessionManager = null
|
||||
) {
|
||||
$this->session = $session;
|
||||
$this->sessionManager = $sessionManager;
|
||||
|
||||
// Initialization happens via SessionData now, no need to set here
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug information only when debug mode is enabled.
|
||||
* Sensitive data like tokens and session IDs are hashed before logging.
|
||||
*/
|
||||
private function debugLog(string $message, array $context = []): void
|
||||
{
|
||||
if (!$this->environment->isDebug()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$logContext = null;
|
||||
if (!empty($context)) {
|
||||
$logContext = LogContext::create();
|
||||
foreach ($context as $key => $value) {
|
||||
// Hash sensitive data (tokens, session IDs)
|
||||
if (in_array($key, ['token', 'session_id', 'stored_token', 'request_token'], true)) {
|
||||
$value = hash('sha256', (string) $value);
|
||||
}
|
||||
$logContext = $logContext->with($key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->debug($message, $logContext);
|
||||
}
|
||||
|
||||
public function generateToken(string $formId): CsrfToken
|
||||
{
|
||||
// Always generate a new token - simplified strategy
|
||||
$token = $this->tokenGenerator->generate();
|
||||
$this->debugLog('CsrfProtection::generateToken - Generating new token', ['form_id' => $formId]);
|
||||
|
||||
// Use atomic update if SessionManager is available, otherwise fall back to regular update
|
||||
if ($this->sessionManager !== null) {
|
||||
$this->debugLog('CsrfProtection::generateToken - Using atomic update', ['form_id' => $formId]);
|
||||
$success = $this->sessionManager->updateSessionDataAtomically(
|
||||
$this->session,
|
||||
function ($data) use ($formId, $token) {
|
||||
$csrfData = $data->csrfData;
|
||||
$tokenData = CsrfTokenData::create($token, $this->clock);
|
||||
|
||||
// Directly set token (one token per form ID)
|
||||
$newCsrfData = $csrfData->withFormData($formId, $tokenData);
|
||||
|
||||
return $data->withCsrfData($newCsrfData);
|
||||
}
|
||||
);
|
||||
|
||||
if (!$success) {
|
||||
$this->debugLog('CsrfProtection::generateToken - Atomic update failed, falling back to regular update');
|
||||
// Fallback to regular update
|
||||
$this->updateSessionDataRegular($formId, $token);
|
||||
} else {
|
||||
$this->debugLog('CsrfProtection::generateToken - Atomic update successful', ['form_id' => $formId]);
|
||||
}
|
||||
} else {
|
||||
$this->debugLog('CsrfProtection::generateToken - SessionManager not available, using regular update', ['form_id' => $formId]);
|
||||
// Fallback to regular update if SessionManager is not available
|
||||
$this->updateSessionDataRegular($formId, $token);
|
||||
}
|
||||
|
||||
$this->debugLog('CsrfProtection::generateToken - Token generated and session updated', ['form_id' => $formId]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function updateSessionDataRegular(string $formId, CsrfToken $token): void
|
||||
{
|
||||
// Get current CSRF data from session
|
||||
$sessionData = $this->session->getSessionData();
|
||||
$csrfData = $sessionData->csrfData;
|
||||
|
||||
// Get tokens for this form
|
||||
$formTokens = $csrfData->getFormData($formId);
|
||||
|
||||
// Add new token
|
||||
$formTokens[] = CsrfTokenData::create($token, $this->clock)->toArray();
|
||||
|
||||
// Cleanup old tokens
|
||||
$formTokens = $this->cleanupOldTokens($formTokens);
|
||||
|
||||
// Update CSRF data
|
||||
$newCsrfData = $csrfData->withFormData($formId, $formTokens);
|
||||
// Directly set token (one token per form ID)
|
||||
$tokenData = CsrfTokenData::create($token, $this->clock);
|
||||
$newCsrfData = $csrfData->withFormData($formId, $tokenData);
|
||||
|
||||
// Update session with new CSRF data
|
||||
$this->session->updateSessionData(function ($data) use ($newCsrfData) {
|
||||
return $data->withCsrfData($newCsrfData);
|
||||
});
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,19 +120,7 @@ final readonly class CsrfProtection
|
||||
*/
|
||||
public function rotateToken(string $formId): CsrfToken
|
||||
{
|
||||
// Invalidate all existing tokens for this form
|
||||
$sessionData = $this->session->getSessionData();
|
||||
$csrfData = $sessionData->csrfData;
|
||||
|
||||
// Clear tokens for this form
|
||||
$newCsrfData = $csrfData->withFormData($formId, []);
|
||||
|
||||
// Update session
|
||||
$this->session->updateSessionData(function ($data) use ($newCsrfData) {
|
||||
return $data->withCsrfData($newCsrfData);
|
||||
});
|
||||
|
||||
// Generate a fresh token
|
||||
// Simply generate a new token (old one will be replaced)
|
||||
return $this->generateToken($formId);
|
||||
}
|
||||
|
||||
@@ -86,93 +131,180 @@ final readonly class CsrfProtection
|
||||
{
|
||||
$sessionData = $this->session->getSessionData();
|
||||
$csrfData = $sessionData->csrfData;
|
||||
$formTokens = $csrfData->getFormData($formId);
|
||||
$tokenData = $csrfData->getFormData($formId);
|
||||
|
||||
if (empty($formTokens)) {
|
||||
if ($tokenData === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentTime = $this->clock->time();
|
||||
|
||||
foreach ($formTokens as $tokenData) {
|
||||
if ($token->equalsString($tokenData['token'])) {
|
||||
$age = $currentTime - $tokenData['created_at'];
|
||||
|
||||
return $age < self::TOKEN_LIFETIME;
|
||||
}
|
||||
if (!$tokenData->matches($token->toString())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
// Check if token is expired
|
||||
$lifetime = Duration::fromSeconds(self::TOKEN_LIFETIME);
|
||||
return !$tokenData->isExpired($this->clock, $lifetime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of active tokens for a form
|
||||
* Simplified: Returns 1 if token exists, 0 otherwise
|
||||
*/
|
||||
public function getActiveTokenCount(string $formId): int
|
||||
{
|
||||
$sessionData = $this->session->getSessionData();
|
||||
$csrfData = $sessionData->csrfData;
|
||||
|
||||
return count($csrfData->getFormData($formId));
|
||||
return $csrfData->hasForm($formId) ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token and return debug information if validation fails
|
||||
*
|
||||
* @return array{valid: bool, debug?: array{session_id: string, token_count: int, available_form_ids: array<string>, token_preview: string}}
|
||||
*/
|
||||
public function validateTokenWithDebug(string $formId, CsrfToken $token): array
|
||||
{
|
||||
// Load fresh session data from storage if SessionManager is available
|
||||
// This ensures we're working with the latest data, not stale data from the session object
|
||||
if ($this->sessionManager !== null) {
|
||||
// Reload session data from storage to get the latest version
|
||||
$freshData = $this->sessionManager->loadSessionData($this->session->id);
|
||||
$sessionData = $freshData;
|
||||
// Update the session object with fresh data
|
||||
$this->session->setSessionData($freshData);
|
||||
} else {
|
||||
// Fallback to session object data if SessionManager is not available
|
||||
$sessionData = $this->session->getSessionData();
|
||||
}
|
||||
|
||||
$csrfData = $sessionData->csrfData;
|
||||
$tokenData = $csrfData->getFormData($formId);
|
||||
$sessionId = $this->session->id->toString();
|
||||
$allFormIds = $csrfData->getFormIds();
|
||||
|
||||
$this->debugLog('CsrfProtection::validateToken - Session ID', ['session_id' => $sessionId]);
|
||||
$this->debugLog('CsrfProtection::validateToken - formId and token', [
|
||||
'form_id' => $formId,
|
||||
'token' => $token->toString()
|
||||
]);
|
||||
|
||||
if ($tokenData === null) {
|
||||
$this->debugLog('CsrfProtection::validateToken - No token found for formId', ['form_id' => $formId]);
|
||||
$this->debugLog('CsrfProtection::validateToken - Available form IDs in session', ['available_form_ids' => $allFormIds]);
|
||||
|
||||
// Check if token exists for another form ID (common mistake)
|
||||
$tokenString = $token->toString();
|
||||
$foundInOtherForm = null;
|
||||
foreach ($allFormIds as $otherFormId) {
|
||||
if ($otherFormId === $formId) {
|
||||
continue;
|
||||
}
|
||||
$otherTokenData = $csrfData->getFormData($otherFormId);
|
||||
if ($otherTokenData !== null && $otherTokenData->matches($tokenString)) {
|
||||
$foundInOtherForm = $otherFormId;
|
||||
error_log("CsrfProtection::validateToken - Token found in different form ID: $otherFormId (requested: $formId)");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => false,
|
||||
'debug' => [
|
||||
'session_id' => $sessionId,
|
||||
'token_count' => 0,
|
||||
'available_form_ids' => $allFormIds,
|
||||
'token_preview' => substr($token->toString(), 0, 20),
|
||||
'reason' => $foundInOtherForm ? "Token belongs to form ID '$foundInOtherForm' but was used for '$formId'" : 'No matching token found in session',
|
||||
'token_belongs_to_form_id' => $foundInOtherForm,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Debug: Log token comparison
|
||||
$storedTokenString = $tokenData->token->toString();
|
||||
$requestTokenString = $token->toString();
|
||||
error_log("CsrfProtection::validateToken - Comparing tokens:");
|
||||
error_log(" Stored: " . substr($storedTokenString, 0, 20) . "... (length: " . strlen($storedTokenString) . ")");
|
||||
error_log(" Request: " . substr($requestTokenString, 0, 20) . "... (length: " . strlen($requestTokenString) . ")");
|
||||
error_log(" Match: " . ($tokenData->matches($token->toString()) ? 'YES' : 'NO'));
|
||||
|
||||
if ($tokenData->matches($token->toString())) {
|
||||
// Check if token is expired
|
||||
$lifetime = Duration::fromSeconds(self::TOKEN_LIFETIME);
|
||||
if ($tokenData->isExpired($this->clock, $lifetime)) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'debug' => [
|
||||
'session_id' => $sessionId,
|
||||
'token_count' => 1,
|
||||
'available_form_ids' => $allFormIds,
|
||||
'token_preview' => substr($token->toString(), 0, 20),
|
||||
'reason' => 'Token has expired',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Token validated - rotate to new token
|
||||
$newToken = $this->tokenGenerator->generate();
|
||||
error_log("CsrfProtection::validateToken - Token validated, rotating to new token for formId: $formId");
|
||||
|
||||
if ($this->sessionManager !== null) {
|
||||
$this->sessionManager->updateSessionDataAtomically(
|
||||
$this->session,
|
||||
function ($data) use ($formId, $newToken) {
|
||||
$csrfData = $data->csrfData;
|
||||
$newTokenData = CsrfTokenData::create($newToken, $this->clock);
|
||||
return $data->withCsrfData($csrfData->withFormData($formId, $newTokenData));
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Fallback to regular update
|
||||
$this->session->updateSessionData(function ($data) use ($formId, $newToken) {
|
||||
$csrfData = $data->csrfData;
|
||||
$newTokenData = CsrfTokenData::create($newToken, $this->clock);
|
||||
return $data->withCsrfData($csrfData->withFormData($formId, $newTokenData));
|
||||
});
|
||||
}
|
||||
|
||||
return ['valid' => true, 'new_token' => $newToken];
|
||||
}
|
||||
|
||||
// No matching token found - add more debug info
|
||||
error_log("CsrfProtection::validateToken - No matching token found. Stored token: " . substr($tokenData->token->toString(), 0, 20) . "...");
|
||||
|
||||
// Check if token exists for another form ID (common mistake)
|
||||
$tokenString = $token->toString();
|
||||
$foundInOtherForm = null;
|
||||
foreach ($allFormIds as $otherFormId) {
|
||||
if ($otherFormId === $formId) {
|
||||
continue;
|
||||
}
|
||||
$otherTokenData = $csrfData->getFormData($otherFormId);
|
||||
if ($otherTokenData !== null && $otherTokenData->matches($tokenString)) {
|
||||
$foundInOtherForm = $otherFormId;
|
||||
error_log("CsrfProtection::validateToken - Token found in different form ID: $otherFormId (requested: $formId)");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => false,
|
||||
'debug' => [
|
||||
'session_id' => $sessionId,
|
||||
'token_count' => 1,
|
||||
'available_form_ids' => $allFormIds,
|
||||
'token_preview' => substr($token->toString(), 0, 20),
|
||||
'reason' => $foundInOtherForm ? "Token belongs to form ID '$foundInOtherForm' but was used for '$formId'" : 'No matching token found in session',
|
||||
'token_belongs_to_form_id' => $foundInOtherForm,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function validateToken(string $formId, CsrfToken $token): bool
|
||||
{
|
||||
$sessionData = $this->session->getSessionData();
|
||||
$csrfData = $sessionData->csrfData;
|
||||
$formTokens = $csrfData->getFormData($formId);
|
||||
|
||||
if (empty($formTokens)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$reSubmitWindow = Duration::fromSeconds(self::RE_SUBMIT_WINDOW);
|
||||
|
||||
foreach ($formTokens as $index => $tokenArray) {
|
||||
$tokenData = CsrfTokenData::fromArray($tokenArray, $this->clock);
|
||||
|
||||
if ($tokenData->matches($token->toString())) {
|
||||
// Check if token was already used and if re-submit window has passed
|
||||
if ($tokenData->usedAt && ! $tokenData->wasUsedRecently($this->clock, $reSubmitWindow)) {
|
||||
return false; // Token was used too long ago
|
||||
}
|
||||
|
||||
// Mark token as used and save back to session
|
||||
$updatedTokenData = $tokenData->markAsUsed($this->clock);
|
||||
$formTokens[$index] = $updatedTokenData->toArray();
|
||||
|
||||
// Update CSRF data with marked token
|
||||
$newCsrfData = $csrfData->withFormData($formId, $formTokens);
|
||||
|
||||
// Update session
|
||||
$this->session->updateSessionData(function ($data) use ($newCsrfData) {
|
||||
return $data->withCsrfData($newCsrfData);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
$result = $this->validateTokenWithDebug($formId, $token);
|
||||
return $result['valid'];
|
||||
}
|
||||
|
||||
private function cleanupOldTokens(array $tokens): array
|
||||
{
|
||||
$lifetime = Duration::fromSeconds(self::TOKEN_LIFETIME);
|
||||
$usedWindow = Duration::fromSeconds(self::USED_TOKEN_CLEANUP_TIME);
|
||||
|
||||
$cleaned = [];
|
||||
|
||||
foreach ($tokens as $tokenArray) {
|
||||
$tokenData = CsrfTokenData::fromArray($tokenArray, $this->clock);
|
||||
|
||||
// Keep tokens that are either not expired or were used recently
|
||||
if ($tokenData->shouldKeep($this->clock, $lifetime, $usedWindow)) {
|
||||
$cleaned[] = $tokenArray; // Keep original array format for session storage
|
||||
}
|
||||
}
|
||||
|
||||
// Keep only the newest N tokens
|
||||
return array_slice($cleaned, -self::MAX_TOKENS_PER_FORM);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user