Replace raw error_log() calls with framework's debugLog() method for: - Consistent structured logging with context data - Sensitive data hashing (tokens, session IDs) - Debug-mode awareness (only logs when debug enabled)
322 lines
13 KiB
PHP
322 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
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;
|
|
|
|
final readonly class CsrfProtection
|
|
{
|
|
private const int TOKEN_LIFETIME = 7200; // 2h - Better UX for long forms
|
|
|
|
private Session $session;
|
|
private ?SessionManager $sessionManager;
|
|
|
|
public function __construct(
|
|
Session $session,
|
|
private CsrfTokenGenerator $tokenGenerator,
|
|
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;
|
|
|
|
// 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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generates a fresh token and rotates the existing one for a form.
|
|
* This should be called after successful form submission.
|
|
*/
|
|
public function rotateToken(string $formId): CsrfToken
|
|
{
|
|
// Simply generate a new token (old one will be replaced)
|
|
return $this->generateToken($formId);
|
|
}
|
|
|
|
/**
|
|
* Checks if a token exists and is valid (not expired)
|
|
*/
|
|
public function isTokenValid(string $formId, CsrfToken $token): bool
|
|
{
|
|
$sessionData = $this->session->getSessionData();
|
|
$csrfData = $sessionData->csrfData;
|
|
$tokenData = $csrfData->getFormData($formId);
|
|
|
|
if ($tokenData === null) {
|
|
return false;
|
|
}
|
|
|
|
if (!$tokenData->matches($token->toString())) {
|
|
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 $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;
|
|
$this->debugLog('CsrfProtection::validateToken - Token found in different form ID', [
|
|
'found_in_form_id' => $otherFormId,
|
|
'requested_form_id' => $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();
|
|
$this->debugLog('CsrfProtection::validateToken - Comparing tokens', [
|
|
'stored_token' => $storedTokenString,
|
|
'request_token' => $requestTokenString,
|
|
'stored_length' => strlen($storedTokenString),
|
|
'request_length' => strlen($requestTokenString),
|
|
'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();
|
|
$this->debugLog('CsrfProtection::validateToken - Token validated, rotating to new token', ['form_id' => $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
|
|
$this->debugLog('CsrfProtection::validateToken - No matching token found', [
|
|
'stored_token' => $tokenData->token->toString()
|
|
]);
|
|
|
|
// 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;
|
|
$this->debugLog('CsrfProtection::validateToken - Token found in different form ID', [
|
|
'found_in_form_id' => $otherFormId,
|
|
'requested_form_id' => $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
|
|
{
|
|
$result = $this->validateTokenWithDebug($formId, $token);
|
|
return $result['valid'];
|
|
}
|
|
|
|
}
|