fix refresh token

This commit is contained in:
O K
2025-10-01 13:22:15 +03:00
parent e0eb907ba2
commit 463c56cee4

View File

@@ -28,6 +28,13 @@ class MauticConnect extends Module
const MAUTIC_ACCESS_TOKEN = 'MAUTICCONNECT_ACCESS_TOKEN'; const MAUTIC_ACCESS_TOKEN = 'MAUTICCONNECT_ACCESS_TOKEN';
const MAUTIC_REFRESH_TOKEN = 'MAUTICCONNECT_REFRESH_TOKEN'; const MAUTIC_REFRESH_TOKEN = 'MAUTICCONNECT_REFRESH_TOKEN';
const MAUTIC_TOKEN_EXPIRES = 'MAUTICCONNECT_TOKEN_EXPIRES'; const MAUTIC_TOKEN_EXPIRES = 'MAUTICCONNECT_TOKEN_EXPIRES';
/**
* A static property to cache the access token for the duration of a single request.
* This prevents multiple API calls to verify/refresh the token within one script execution.
* @var array|null
*/
private static $runtimeCache = null;
// --- DATA-DRIVEN EVENT DEFINITIONS --- // --- DATA-DRIVEN EVENT DEFINITIONS ---
// To add a new event, simply add a new entry to this array. // To add a new event, simply add a new entry to this array.
@@ -384,7 +391,7 @@ class MauticConnect extends Module
} }
// ... displayConnectionStatus, getMauticAuthUrl, getOauth2RedirectUri ... // ... displayConnectionStatus, getMauticAuthUrl, getOauth2RedirectUri ...
// ... makeApiRequest, refreshTokenIfNeeded ...
// ... hookActionCustomerAccountAdd, hookActionObjectCustomerUpdateAfter, syncCustomer ... // ... hookActionCustomerAccountAdd, hookActionObjectCustomerUpdateAfter, syncCustomer ...
// ... sendOrderEmail, findContactByEmail, and all other helpers remain mostly unchanged ... // ... sendOrderEmail, findContactByEmail, and all other helpers remain mostly unchanged ...
@@ -543,6 +550,128 @@ class MauticConnect extends Module
return $this->context->link->getModuleLink($this->name, 'oauth2', [], true); return $this->context->link->getModuleLink($this->name, 'oauth2', [], true);
} }
/**
* Gets a guaranteed valid Mautic access token.
*
* This function uses a three-level approach for maximum reliability and efficiency:
* 1. Runtime Cache: Returns a token if one was already validated in the current script execution.
* 2. Database Cache + Live Ping: Uses the stored token but verifies it with a quick API call.
* 3. Token Refresh: If the token is expired or invalid, uses the refresh token to get a new one.
*
* This is the ONLY function you should call to get a token before making an API request.
*
* @return string The valid access token.
* @throws Exception if a valid token cannot be obtained.
*/
public function getValidAccessToken(): string
{
// Level 1: Check the runtime cache for this script execution.
if (self::$runtimeCache && time() < self::$runtimeCache['expires']) {
return self::$runtimeCache['token'];
}
$mauticUrl = Configuration::get(self::MAUTIC_URL);
if (!$mauticUrl || !Configuration::get(self::MAUTIC_CLIENT_ID)) {
throw new DomainException('Mautic is not configured. Please check the module settings.');
}
$accessToken = Configuration::get(self::MAUTIC_ACCESS_TOKEN);
$expiresAt = (int)Configuration::get(self::MAUTIC_TOKEN_EXPIRES);
// Level 2: Check the database token and perform a live "ping" to verify it.
if ($accessToken && time() < $expiresAt) {
try {
$client = HttpClient::create(['auth_bearer' => $accessToken]);
// A simple, low-impact API call to check if the token is still valid.
$client->request('GET', $mauticUrl . '/api/contacts?limit=1');
// If the ping succeeds, the token is valid. Cache it for this runtime and return.
self::$runtimeCache = ['token' => $accessToken, 'expires' => $expiresAt];
return $accessToken;
} catch (ClientExceptionInterface $e) {
if ($e->getResponse()->getStatusCode() !== 401) {
// It's a different error (e.g., 404, 500). Log and fail fast.
PrestaShopLogger::addLog(
sprintf(
'Mautic API ping failed with status %d. Response: %s',
$e->getResponse()->getStatusCode(),
$e->getResponse()->getContent(false)
),
3 // Severity 3: Error
);
throw new Exception('Mautic API is not responding correctly. Please check Mautic status.', 0, $e);
}
// If it IS a 401, the token is dead. Log it and proceed to the refresh logic below.
PrestaShopLogger::addLog('Mautic token from database was rejected (401). Forcing refresh.', 1);
} catch (Exception $e) {
PrestaShopLogger::addLog('Mautic API ping failed with a transport error: ' . $e->getMessage(), 3);
throw new Exception('Could not connect to Mautic to verify the token.', 0, $e);
}
}
// Level 3: The token is expired or was proven invalid. We MUST refresh.
PrestaShopLogger::addLog('Attempting to refresh Mautic token.', 1);
$refreshToken = Configuration::get(self::MAUTIC_REFRESH_TOKEN);
if (!$refreshToken) {
PrestaShopLogger::addLog('Cannot refresh Mautic token: Refresh Token is missing. Please re-authenticate the module.', 4);
throw new DomainException('Mautic Refresh Token is missing. Please reconnect the module.');
}
try {
$client = HttpClient::create();
$response = $client->request('POST', $mauticUrl . '/oauth/v2/token', [
'body' => [
'client_id' => Configuration::get(self::MAUTIC_CLIENT_ID),
'client_secret' => Configuration::get(self::MAUTIC_CLIENT_SECRET),
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
],
]);
$data = $response->toArray(); // Throws on non-2xx responses
if (empty($data['access_token'])) {
PrestaShopLogger::addLog('Mautic refresh response was successful but did not contain an access_token. Response: ' . json_encode($data), 4);
throw new Exception('Invalid Mautic refresh response.');
}
// SUCCESS! Save everything to the database AND the runtime cache.
$newAccessToken = $data['access_token'];
$newRefreshToken = $data['refresh_token'];
$newExpiresAt = time() + (int)$data['expires_in'] - 120; // 120s safety buffer
Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, $newAccessToken);
Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, $newRefreshToken);
Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, $newExpiresAt);
self::$runtimeCache = ['token' => $newAccessToken, 'expires' => $newExpiresAt];
PrestaShopLogger::addLog('Successfully refreshed Mautic access token.', 1);
return $newAccessToken;
} catch (Exception $e) {
$errorContext = 'CRITICAL: Mautic token refresh failed: ' . $e->getMessage();
if ($e instanceof ClientExceptionInterface) {
$errorContext .= ' | Mautic Response: ' . $e->getResponse()->getContent(false);
}
PrestaShopLogger::addLog($errorContext, 4); // Severity 4: CRITICAL
// Invalidate the expired access token. DO NOT touch the refresh token unless Mautic
// explicitly tells us it's invalid.
Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, '');
Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, 0);
if ($e instanceof ClientExceptionInterface && str_contains($e->getResponse()->getContent(false), 'invalid_grant')) {
// This is the only case where the refresh token is truly dead.
Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, '');
throw new Exception('Mautic refresh token is invalid or expired. Please reconnect the module in the configuration page.', 0, $e);
}
// For all other errors (network issues, Mautic down), we keep the refresh token to try again later.
throw new Exception('Could not refresh Mautic token. The system will try again on the next run.', 0, $e);
}
}
/* Core function to make any request to the Mautic API using Symfony HTTP Client. /* Core function to make any request to the Mautic API using Symfony HTTP Client.
* It handles token refreshing, authentication headers, and error checking. * It handles token refreshing, authentication headers, and error checking.
@@ -555,9 +684,18 @@ class MauticConnect extends Module
*/ */
private function makeApiRequest($endpoint, $method = 'GET', $data = []) private function makeApiRequest($endpoint, $method = 'GET', $data = [])
{ {
$this->refreshTokenIfNeeded(); try {
$accessToken = $this->getValidAccessToken();
} catch (Exception $e) {
// The getValidAccessToken function already logged the critical error.
// We just log the consequence and stop this specific operation.
PrestaShopLogger::addLog(
'valid Mautic token could not be obtained. Error: ' . $e->getMessage(),
3 // Severity 3: Error
);
return false; // Or handle the failure as needed
}
$accessToken = Configuration::get(self::MAUTIC_ACCESS_TOKEN);
$mauticUrl = Configuration::get(self::MAUTIC_URL); $mauticUrl = Configuration::get(self::MAUTIC_URL);
/** @var \Symfony\Contracts\HttpClient\HttpClientInterface $client */ /** @var \Symfony\Contracts\HttpClient\HttpClientInterface $client */
@@ -599,27 +737,97 @@ class MauticConnect extends Module
} }
/** /**
* Checks if the access token is expired and uses the refresh token to get a new one. * Verifies the Mautic API connection by making a lightweight API call.
* If the call fails due to an expired token (401), it triggers a token refresh.
* This function should be called before making any critical API calls to Mautic.
* *
* @throws Exception if refreshing the token fails. * @throws Exception if the connection is invalid and cannot be refreshed.
*/ */
private function refreshTokenIfNeeded() public function verifyConnectionAndRefresh(): void
{
$accessToken = Configuration::get(self::MAUTIC_ACCESS_TOKEN);
$mauticUrl = Configuration::get(self::MAUTIC_URL);
if (!$accessToken || !$mauticUrl) {
// Not configured yet, nothing to do.
PrestaShopLogger::addLog('Mautic connection check skipped: module is not configured.', 1);
return;
}
// First, check the local expiry time. If it's not expired, do a quick "ping".
$expiresAt = (int)Configuration::get(self::MAUTIC_TOKEN_EXPIRES);
if (time() < $expiresAt) {
try {
$client = HttpClient::create([
'auth_bearer' => $accessToken,
]);
// A simple, low-impact API call to check if the token is still valid.
$client->request('GET', $mauticUrl . '/api/contacts?limit=1');
// If the above line doesn't throw, the token is valid. We are done.
return;
} catch (ClientExceptionInterface $e) {
// Check if it's an authentication error (token expired/revoked)
if ($e->getResponse()->getStatusCode() === 401) {
PrestaShopLogger::addLog('Mautic access token is invalid or expired. Attempting to refresh.', 1); // Severity 1: Informational
// The token is invalid, proceed to refresh it below.
} else {
// Another client error (e.g., 404 Not Found, 403 Forbidden). Log and re-throw.
PrestaShopLogger::addLog(
sprintf(
'Mautic API ping failed with status code %d. Error: %s',
$e->getResponse()->getStatusCode(),
$e->getResponse()->getContent(false)
),
3 // Severity 3: Error
);
throw new Exception('Mautic API ping failed. Please check module configuration and Mautic API status.', 0, $e);
}
} catch (Exception $e) {
// Other errors like network issues.
PrestaShopLogger::addLog('Mautic API ping failed with a general error: ' . $e->getMessage(), 3);
throw new Exception('Could not connect to Mautic for ping test.', 0, $e);
}
}
// If the code reaches here, it's either because the token is expired locally
// or the ping returned a 401. Time to refresh.
try {
$this->refreshToken(true); // Force refresh
} catch (Exception $e) {
// The refresh itself failed. The original exception from refreshToken is already logged.
// We re-throw it to stop the current process.
throw $e;
}
}
/**
* Checks if the access token is expired and uses the refresh token to get a new one.
* This version is made more resilient and provides detailed logging.
*
* @param bool $force Forces a refresh attempt even if the token is not expired locally.
* @return string The new expiry date.
* @throws Exception if refreshing the token fails critically.
*/
public function refreshToken(bool $force = false): string
{ {
$expiresAt = (int)Configuration::get(self::MAUTIC_TOKEN_EXPIRES); $expiresAt = (int)Configuration::get(self::MAUTIC_TOKEN_EXPIRES);
if (time() < $expiresAt) { if ((time() + 3600) < $expiresAt && !$force) {
return; // Token is still valid return date("Y-m-d H:i:s", $expiresAt); // Token is still valid
} }
$mauticUrl = Configuration::get(self::MAUTIC_URL); $mauticUrl = Configuration::get(self::MAUTIC_URL);
$refreshToken = Configuration::get(self::MAUTIC_REFRESH_TOKEN); $refreshToken = Configuration::get(self::MAUTIC_REFRESH_TOKEN);
if (!$refreshToken) { if (!$mauticUrl || !$refreshToken) {
throw new Exception('Cannot refresh token: Refresh token is missing.'); // Log this critical state
PrestaShopLogger::addLog('Cannot refresh Mautic token: Mautic URL or Refresh Token is missing. Please re-authenticate.', 4); // Severity 4: Critical
throw new DomainException('Cannot refresh token: Mautic URL or Refresh token is missing in configuration.');
} }
$client = HttpClient::create(); $client = HttpClient::create();
$postData = [ $postData = [
'client_id' => Configuration::get(self::MAUTIC_CLIENT_ID), 'client_id' => Configuration::get(self::MAUTIC_CLIENT_ID),
'client_secret' => Configuration::get(self::MAUTIC_CLIENT_SECRET), 'client_secret' => Configuration::get(self::MAUTIC_CLIENT_SECRET),
@@ -629,27 +837,50 @@ class MauticConnect extends Module
try { try {
$response = $client->request('POST', $mauticUrl . '/oauth/v2/token', [ $response = $client->request('POST', $mauticUrl . '/oauth/v2/token', [
// Symfony client correctly encodes this as application/x-www-form-urlencoded
'body' => $postData, 'body' => $postData,
]); ]);
$data = $response->toArray(); $data = $response->toArray(); // Throws exception on non-2xx response
if (!isset($data['access_token'])) { if (!isset($data['access_token'])) {
PrestaShopLogger::addLog('Mautic refresh response did not contain an access_token. Response: ' . json_encode($data), 4);
throw new Exception('Mautic response did not contain an access_token.'); throw new Exception('Mautic response did not contain an access_token.');
} }
// Success! Save the new tokens and expiry time. // Success! Save the new tokens and expiry time.
Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, $data['access_token']); Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, $data['access_token']);
Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, $data['refresh_token']); Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, $data['refresh_token']);
$expiresAt = time() + (int)$data['expires_in'] - 60; // 60s buffer $newExpiresAt = time() + (int)$data['expires_in'] - 120; // 120s buffer for safety
Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, $expiresAt); Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, $newExpiresAt);
PrestaShopLogger::addLog('Successfully refreshed Mautic access token. New expiry: ' . date("Y-m-d H:i:s", $newExpiresAt), 1);
return date("Y-m-d H:i:s", $newExpiresAt);
} catch (Exception $e) { } catch (Exception $e) {
// Critical failure: we can no longer authenticate. // This is the CRITICAL change. We log with high severity but DO NOT delete the refresh token.
$errorContext = 'Mautic token refresh failed: ' . $e->getMessage();
// Try to get the specific API error message from Mautic for better debugging
if ($e instanceof ClientExceptionInterface) {
$errorContext .= ' | Mautic Response: ' . $e->getResponse()->getContent(false);
}
PrestaShopLogger::addLog($errorContext, 4); // Severity 4: CRITICAL ERROR
// Invalidate the CURRENT access token, as it's clearly not working.
// DO NOT delete the refresh token. It's our only chance to recover.
Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, ''); Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, '');
Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, '');
Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, 0); Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, 0);
throw new Exception('Failed to refresh Mautic token. Please reconnect the module. Error: ' . $e->getMessage(), 0, $e);
// If the error response indicates the refresh token itself is invalid, then we can't recover.
if ($e instanceof ClientExceptionInterface && str_contains($e->getResponse()->getContent(false), 'invalid_grant')) {
// Now it's truly a fatal error. The refresh token is dead.
Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, '');
throw new Exception('Failed to refresh Mautic token because the refresh token is invalid or expired. Please reconnect the module in the configuration page. Original Error: ' . $e->getMessage(), 0, $e);
}
// For other errors (network, Mautic down), we throw an exception to halt the current operation,
// but we preserve the refresh token so the system can try again later.
throw new Exception('Failed to refresh Mautic token. The system will try again later. Error: ' . $e->getMessage(), 0, $e);
} }
} }