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