- Fix Client ID regex to allow lowercase letters - Update HVAC_Zoho_CRM_Auth to use encrypted storage for all operations - Update class-zoho-admin.php to use HVAC_Secure_Storage for credential retrieval - Update OAuth callback to use secure storage for token storage - Update Status.md with blocking production issue (400 Bad Request) Note: Issue persists on production - needs further investigation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
501 lines
No EOL
17 KiB
PHP
501 lines
No EOL
17 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
|
|
*/
|
|
public function make_api_request($endpoint, $method = 'GET', $data = null) {
|
|
// Check if we're in staging mode
|
|
$site_url = get_site_url();
|
|
$is_staging = strpos($site_url, 'upskillhvac.com') === false;
|
|
|
|
// 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: Simulating ' . $method . ' request to ' . $endpoint);
|
|
return array(
|
|
'data' => array(
|
|
array(
|
|
'code' => 'STAGING_MODE',
|
|
'details' => array(
|
|
'message' => 'Staging mode active. Write operations are disabled.'
|
|
),
|
|
'message' => 'This would have been a ' . $method . ' request to: ' . $endpoint,
|
|
'status' => 'success'
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
$url = 'https://www.zohoapis.com/crm/v2' . $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, 255) . (strlen($body) > 255 ? '...' : '')
|
|
);
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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
|
|
*/
|
|
private 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')
|
|
);
|
|
}
|
|
} |