Files
michaelschiemer/src/Framework/Http/Session/CsrfProtection.php
Michael Schiemer 77505edabf
All checks were successful
Test Runner / test-basic (push) Successful in 8s
Test Runner / test-php (push) Successful in 8s
Deploy Application / deploy (push) Successful in 45s
refactor(csrf): replace error_log with debugLog for structured logging
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)
2025-11-25 03:52:57 +01:00

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