upskill-event-manager/includes/zoho/class-zoho-crm-auth.php
ben 03b9bce52d fix(zoho): Fix silent sync failures with API response validation and hash reset
Zoho CRM sync appeared connected but silently failed to write data due to
unvalidated API responses. Sync methods now validate Zoho responses before
updating hashes, ensuring failed records re-sync on next run. Also fixes
staging detection to use wp_parse_url hostname parsing instead of fragile
strpos matching, adds admin UI for resetting sync hashes, and bumps
HVAC_PLUGIN_VERSION to 2.2.11 to bust browser cache for updated JS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 11:25:26 -04:00

573 lines
No EOL
20 KiB
PHP

<?php
/**
* Zoho CRM Authentication Handler
*
* Handles OAuth token management and API authentication
*
* @package HVAC_Community_Events
* @subpackage Zoho_Integration
*/
if (!defined('ABSPATH')) {
exit;
}
class HVAC_Zoho_CRM_Auth {
private $client_id;
private $client_secret;
private $refresh_token;
private $redirect_uri;
private $access_token;
private $token_expiry;
private $last_error = null;
public function __construct() {
// Load secure storage class
if (!class_exists('HVAC_Secure_Storage')) {
require_once plugin_dir_path(dirname(__FILE__)) . 'class-hvac-secure-storage.php';
}
// Load credentials from WordPress options using secure storage (encrypted)
$this->client_id = HVAC_Secure_Storage::get_credential('hvac_zoho_client_id', '');
$this->client_secret = HVAC_Secure_Storage::get_credential('hvac_zoho_client_secret', '');
$this->refresh_token = HVAC_Secure_Storage::get_credential('hvac_zoho_refresh_token', '');
$this->redirect_uri = get_site_url() . '/oauth/callback';
// Fallback to config file if options are empty (backward compatibility)
if (empty($this->client_id) || empty($this->client_secret)) {
$config_file = plugin_dir_path(__FILE__) . 'zoho-config.php';
if (file_exists($config_file)) {
require_once $config_file;
$this->client_id = empty($this->client_id) && defined('ZOHO_CLIENT_ID') ? ZOHO_CLIENT_ID : $this->client_id;
$this->client_secret = empty($this->client_secret) && defined('ZOHO_CLIENT_SECRET') ? ZOHO_CLIENT_SECRET : $this->client_secret;
$this->refresh_token = empty($this->refresh_token) && defined('ZOHO_REFRESH_TOKEN') ? ZOHO_REFRESH_TOKEN : $this->refresh_token;
$this->redirect_uri = defined('ZOHO_REDIRECT_URI') ? ZOHO_REDIRECT_URI : $this->redirect_uri;
}
}
// Load stored access token from WordPress options
$this->load_access_token();
}
/**
* Generate authorization URL for initial setup
*
* @return string Authorization URL with CSRF state parameter
*/
public function get_authorization_url() {
// Generate secure state parameter for CSRF protection
$state = $this->generate_oauth_state();
$params = array(
'scope' => 'ZohoCRM.settings.ALL,ZohoCRM.modules.ALL,ZohoCRM.users.ALL,ZohoCRM.org.ALL,ZohoCRM.bulk.READ',
'client_id' => $this->client_id,
'response_type' => 'code',
'access_type' => 'offline',
'redirect_uri' => $this->redirect_uri,
'prompt' => 'consent',
'state' => $state
);
return 'https://accounts.zoho.com/oauth/v2/auth?' . http_build_query($params);
}
/**
* Generate and store OAuth state parameter for CSRF protection
*
* @return string Generated state token
*/
public function generate_oauth_state() {
$state = wp_generate_password(32, false);
set_transient('hvac_zoho_oauth_state', $state, 600); // 10 minute expiry
return $state;
}
/**
* Validate OAuth state parameter
*
* @param string $state State parameter from callback
* @return bool True if state is valid
*/
public function validate_oauth_state($state) {
$stored_state = get_transient('hvac_zoho_oauth_state');
if (empty($stored_state) || empty($state)) {
return false;
}
// Use timing-safe comparison
$valid = hash_equals($stored_state, $state);
// Delete the state after validation (one-time use)
delete_transient('hvac_zoho_oauth_state');
return $valid;
}
/**
* Exchange authorization code for tokens
*/
public function exchange_code_for_tokens($auth_code) {
$url = 'https://accounts.zoho.com/oauth/v2/token';
$params = array(
'grant_type' => 'authorization_code',
'client_id' => $this->client_id,
'client_secret' => $this->client_secret,
'redirect_uri' => $this->redirect_uri,
'code' => $auth_code
);
$response = wp_remote_post($url, array(
'body' => $params,
'headers' => array(
'Content-Type' => 'application/x-www-form-urlencoded'
)
));
if (is_wp_error($response)) {
$this->log_error('Failed to exchange code: ' . $response->get_error_message());
return false;
}
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if (isset($data['access_token']) && isset($data['refresh_token'])) {
$this->access_token = $data['access_token'];
$this->refresh_token = $data['refresh_token'];
$this->token_expiry = time() + $data['expires_in'];
// Save tokens
$this->save_tokens();
return true;
}
$this->log_error('Invalid token response: ' . $body);
return false;
}
/**
* Get valid access token (refresh if needed)
*/
public function get_access_token() {
// Check if token is expired or will expire soon (5 mins buffer)
if (!$this->access_token || (time() + 300) >= $this->token_expiry) {
$this->refresh_access_token();
}
return $this->access_token;
}
/**
* Refresh access token using refresh token
*/
private function refresh_access_token() {
$url = 'https://accounts.zoho.com/oauth/v2/token';
$params = array(
'refresh_token' => $this->refresh_token,
'client_id' => $this->client_id,
'client_secret' => $this->client_secret,
'grant_type' => 'refresh_token'
);
$response = wp_remote_post($url, array(
'body' => $params,
'headers' => array(
'Content-Type' => 'application/x-www-form-urlencoded'
)
));
if (is_wp_error($response)) {
$this->log_error('Failed to refresh token: ' . $response->get_error_message());
return false;
}
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if (isset($data['access_token'])) {
$this->access_token = $data['access_token'];
$this->token_expiry = time() + $data['expires_in'];
$this->save_access_token();
return true;
}
$this->log_error('Failed to refresh token: ' . $body);
return false;
}
/**
* Make authenticated API request
*/
/**
* Check if we are in staging mode
*
* @return bool True if in staging mode
*/
public static function is_staging_mode() {
// 1. Allow forcing production mode via constant
if (defined('HVAC_ZOHO_PRODUCTION_MODE') && HVAC_ZOHO_PRODUCTION_MODE) {
return false;
}
// 2. Allow forcing staging mode via constant
if (defined('HVAC_ZOHO_STAGING_MODE') && HVAC_ZOHO_STAGING_MODE) {
return true;
}
// 3. Parse hostname from site URL for accurate comparison
$site_url = get_site_url();
$host = wp_parse_url($site_url, PHP_URL_HOST);
if (empty($host)) {
return true; // Can't determine host, default to staging for safety
}
// 4. Production: upskillhvac.com or www.upskillhvac.com
if ($host === 'upskillhvac.com' || $host === 'www.upskillhvac.com') {
return false;
}
// 5. Everything else is staging (including staging subdomains, cloudwaysapps, localhost, etc.)
return true;
}
/**
* Get details about how the mode was determined (for debugging)
*
* @return array Debug information
*/
public static function get_debug_mode_info() {
$site_url = get_site_url();
$host = wp_parse_url($site_url, PHP_URL_HOST);
$info = array(
'site_url' => $site_url,
'parsed_host' => $host,
'is_staging' => self::is_staging_mode(),
'forced_production' => defined('HVAC_ZOHO_PRODUCTION_MODE') && HVAC_ZOHO_PRODUCTION_MODE,
'forced_staging' => defined('HVAC_ZOHO_STAGING_MODE') && HVAC_ZOHO_STAGING_MODE,
'detection_logic' => array()
);
// Replicate logic to show which rule matched
if ($info['forced_production']) {
$info['detection_logic'][] = 'Forced PRODUCTION via HVAC_ZOHO_PRODUCTION_MODE constant';
} elseif ($info['forced_staging']) {
$info['detection_logic'][] = 'Forced STAGING via HVAC_ZOHO_STAGING_MODE constant';
} elseif (empty($host)) {
$info['detection_logic'][] = 'STAGING: Could not parse hostname from URL';
} elseif ($host === 'upskillhvac.com' || $host === 'www.upskillhvac.com') {
$info['detection_logic'][] = 'PRODUCTION: Hostname matches upskillhvac.com';
} else {
$info['detection_logic'][] = 'STAGING: Hostname "' . $host . '" is not upskillhvac.com';
}
return $info;
}
/**
* Make authenticated API request
*/
public function make_api_request($endpoint, $method = 'GET', $data = null) {
// Check if we're in staging mode
$is_staging = self::is_staging_mode();
// In staging mode, only allow read operations, no writes
if ($is_staging && in_array($method, array('POST', 'PUT', 'DELETE', 'PATCH'))) {
$this->log_debug('STAGING MODE: Blocked ' . $method . ' request to ' . $endpoint);
return array(
'data' => array(
array(
'code' => 'STAGING_MODE',
'details' => array(
'message' => 'Staging mode active. Write operations are disabled.'
),
'message' => 'Blocked ' . $method . ' request to: ' . $endpoint,
'status' => 'skipped_staging'
)
)
);
}
// Debug logging of config status
if (defined('ZOHO_DEBUG_MODE') && ZOHO_DEBUG_MODE) {
$config_status = $this->get_configuration_status();
$this->log_debug('Configuration status: ' . json_encode($config_status));
if (!$config_status['client_id_exists']) {
$this->log_error('Client ID is missing or empty');
}
if (!$config_status['client_secret_exists']) {
$this->log_error('Client Secret is missing or empty');
}
if (!$config_status['refresh_token_exists']) {
$this->log_error('Refresh Token is missing or empty');
}
if ($config_status['token_expired']) {
$this->log_debug('Access token is expired, will attempt to refresh');
}
}
$access_token = $this->get_access_token();
if (!$access_token) {
$error_message = 'No valid access token available';
$this->log_error($error_message);
return new WP_Error('no_token', $error_message);
}
// Update to v6 API (v2 is deprecated)
$url = 'https://www.zohoapis.com/crm/v6' . $endpoint;
// Log the request details
$this->log_debug('Making ' . $method . ' request to: ' . $url);
$args = array(
'method' => $method,
'headers' => array(
'Authorization' => 'Zoho-oauthtoken ' . $access_token,
'Content-Type' => 'application/json'
),
'timeout' => 30 // Increase timeout to 30 seconds for potentially slow responses
);
if ($data && in_array($method, array('POST', 'PUT', 'PATCH'))) {
$args['body'] = json_encode($data);
$this->log_debug('Request payload: ' . json_encode($data));
}
// Execute the request
$this->log_debug('Executing request to Zoho API');
$response = wp_remote_request($url, $args);
// Handle WordPress errors
if (is_wp_error($response)) {
$error_message = 'API request failed: ' . $response->get_error_message();
$error_data = $response->get_error_data();
$this->log_error($error_message);
$this->log_debug('Error details: ' . json_encode($error_data));
return $response;
}
// Get response code and body
$status_code = wp_remote_retrieve_response_code($response);
$headers = wp_remote_retrieve_headers($response);
$body = wp_remote_retrieve_body($response);
$this->log_debug('Response code: ' . $status_code);
// Log headers for debugging
if (defined('ZOHO_DEBUG_MODE') && ZOHO_DEBUG_MODE) {
$this->log_debug('Response headers: ' . json_encode($headers->getAll()));
}
// Handle empty responses
if (empty($body)) {
$error_message = 'Empty response received from Zoho API';
$this->log_error($error_message);
return array(
'error' => $error_message,
'code' => $status_code,
'details' => 'No response body received'
);
}
// Parse the JSON response
$data = json_decode($body, true);
// Check for JSON parsing errors
if ($data === null && json_last_error() !== JSON_ERROR_NONE) {
$error_message = 'Invalid JSON response: ' . json_last_error_msg();
$this->log_error($error_message);
$this->log_debug('Raw response: ' . $body);
return array(
'error' => $error_message,
'code' => 'JSON_PARSE_ERROR',
'details' => 'Raw response: ' . substr($body, 0, 500),
'request_payload' => isset($args['body']) ? $args['body'] : 'No body'
);
}
// Log response for debugging
if (defined('ZOHO_DEBUG_MODE') && ZOHO_DEBUG_MODE) {
$this->log_debug('API Response: ' . $body);
}
// Check for API errors
if ($status_code >= 400) {
$error_message = isset($data['message']) ? $data['message'] : 'API error with status code ' . $status_code;
$this->log_error($error_message);
// Add HTTP error information to the response
$data['http_status'] = $status_code;
$data['error'] = $error_message;
$data['request_payload'] = isset($args['body']) ? $args['body'] : 'No body';
// Extract more detailed error information if available
if (isset($data['code'])) {
$this->log_debug('Error code: ' . $data['code']);
}
if (isset($data['details'])) {
$this->log_debug('Error details: ' . json_encode($data['details']));
}
}
return $data;
}
/**
* Save tokens to WordPress options using secure storage
*/
private function save_tokens() {
HVAC_Secure_Storage::store_credential('hvac_zoho_refresh_token', $this->refresh_token);
$this->save_access_token();
}
/**
* Save access token using secure storage
*/
private function save_access_token() {
HVAC_Secure_Storage::store_credential('hvac_zoho_access_token', $this->access_token);
update_option('hvac_zoho_token_expiry', $this->token_expiry);
}
/**
* Load access token from WordPress options using secure storage
*/
private function load_access_token() {
$this->access_token = HVAC_Secure_Storage::get_credential('hvac_zoho_access_token', '');
$this->token_expiry = get_option('hvac_zoho_token_expiry', 0);
// Load refresh token if not set
if (!$this->refresh_token) {
$this->refresh_token = HVAC_Secure_Storage::get_credential('hvac_zoho_refresh_token', '');
}
}
/**
* Log error messages
*/
private function log_error($message) {
$this->last_error = $message;
if (defined('ZOHO_LOG_FILE')) {
error_log('[' . date('Y-m-d H:i:s') . '] ERROR: ' . $message . PHP_EOL, 3, ZOHO_LOG_FILE);
}
// Also log to WordPress debug log if available
if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
error_log('[ZOHO CRM] ' . $message);
}
}
/**
* Log debug messages
*/
public function log_debug($message) {
// Sanitize message to remove sensitive data
$sanitized = $this->sanitize_log_message($message);
if (defined('ZOHO_DEBUG_MODE') && ZOHO_DEBUG_MODE && defined('ZOHO_LOG_FILE')) {
error_log('[' . date('Y-m-d H:i:s') . '] DEBUG: ' . $sanitized . PHP_EOL, 3, ZOHO_LOG_FILE);
}
// Also log to WordPress debug log if available
if (defined('ZOHO_DEBUG_MODE') && ZOHO_DEBUG_MODE && defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
error_log('[ZOHO CRM DEBUG] ' . $sanitized);
}
}
/**
* Sanitize log messages to mask sensitive credentials
*
* @param string $message Log message
* @return string Sanitized message
*/
private function sanitize_log_message($message) {
// Mask client_id, client_secret, access_token, refresh_token patterns
$patterns = array(
'/(client[_-]?(id|secret)[\s:=]+)([a-zA-Z0-9._-]{10,})/i',
'/(access[_-]?token[\s:=]+)([a-zA-Z0-9._-]{10,})/i',
'/(refresh[_-]?token[\s:=]+)([a-zA-Z0-9._-]{10,})/i',
'/(authorization[\s:]+)(Zoho-oauthtoken\s+[a-zA-Z0-9._-]+)/i',
'/("(client_id|client_secret|access_token|refresh_token)"[\s:]+")[^"]+(")/i',
);
$replacements = array(
'$1***MASKED***',
'$1***MASKED***',
'$1***MASKED***',
'$1Zoho-oauthtoken ***MASKED***',
'$1***MASKED***$3',
);
return preg_replace($patterns, $replacements, $message);
}
/**
* Get the last error message
*
* @return string|null
*/
public function get_last_error() {
return $this->last_error;
}
/**
* Get client ID (for debugging only)
*
* @return string
*/
public function get_client_id() {
return $this->client_id;
}
/**
* Check if client secret exists (for debugging only)
*
* @return bool
*/
public function get_client_secret() {
return !empty($this->client_secret);
}
/**
* Check if refresh token exists (for debugging only)
*
* @return bool
*/
public function get_refresh_token() {
return !empty($this->refresh_token);
}
/**
* Get configuration status (for debugging)
*
* @return array
*/
public function get_configuration_status() {
return array(
'client_id_exists' => !empty($this->client_id),
'client_secret_exists' => !empty($this->client_secret),
'refresh_token_exists' => !empty($this->refresh_token),
'access_token_exists' => !empty($this->access_token),
'token_expired' => $this->token_expiry < time(),
'config_loaded' => file_exists(plugin_dir_path(__FILE__) . 'zoho-config.php')
);
}
}