diff --git a/composer.json b/composer.json index 8bf5c83..41edd5d 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,9 @@ "phpunit/phpunit": "*", "phpstan/phpstan": "^2.0" }, + "suggest": { + "ext-apcu": "Optional: required only by the built-in ApcuSessionCache backend." + }, "autoload": { "psr-4": { "INTERMediator\\FileMakerServer\\RESTAPI\\": "src/" diff --git a/src/FMDataAPI.php b/src/FMDataAPI.php index 24047eb..6cd1f8e 100644 --- a/src/FMDataAPI.php +++ b/src/FMDataAPI.php @@ -2,6 +2,7 @@ namespace INTERMediator\FileMakerServer\RESTAPI; +use INTERMediator\FileMakerServer\RESTAPI\SessionCache\AbstractSessionCache; use INTERMediator\FileMakerServer\RESTAPI\Supporting\FileMakerLayout; use INTERMediator\FileMakerServer\RESTAPI\Supporting\FileMakerRelation; use INTERMediator\FileMakerServer\RESTAPI\Supporting\CommunicationProvider; @@ -58,23 +59,33 @@ class FMDataAPI * Ex. [{"database"=>"", "username"=>"", "password"=>""}]. * If you use OAuth, "oAuthRequestId" and "oAuthIdentifier" keys have to be specified. * @param boolean $isUnitTest If it's set to true, the communication provider just works locally. - */ - public function __construct(string $solution, - string $user, - string|null $password, - string|null $host = null, - int|null $port = null, - string|null $protocol = null, - array|null $fmDataSource = null, - bool $isUnitTest = false) + * @param AbstractSessionCache|null $sessionCache Cache backend for persistent sessions. + * If omitted, the library logs in and out on every database operation, or once + * per communication scope when using startCommunication() / endCommunication(). + * If specified, session tokens are persisted and reused across requests via + * startCommunication() / endCommunication(), avoiding redundant logins against the FileMaker Server. + * When a session cache is specified, {@see self::setRetryOnAccessTokenInvalidation()} is + * automatically set to true, ensuring the library re-authenticates and retries the request if + * the cached token has expired on the FileMaker Server. + */ + public function __construct(string $solution, + string $user, + string|null $password, + string|null $host = null, + int|null $port = null, + string|null $protocol = null, + array|null $fmDataSource = null, + bool $isUnitTest = false, + AbstractSessionCache|null $sessionCache = null) { if (is_null($password)) { $password = "password"; // For testing purpose. } + if (!$isUnitTest) { - $this->provider = new Supporting\CommunicationProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource); + $this->provider = new Supporting\CommunicationProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource, $sessionCache); } else { - $this->provider = new Supporting\TestProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource); + $this->provider = new Supporting\TestProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource, $sessionCache); } } @@ -264,33 +275,42 @@ public function setThrowException(bool $value): void } /** - * Start a transaction which is a serial calling of multiple database operations before the single authentication. - * Usually most methods login and logout before/after the database operation, and so a little bit of time is going to - * take. - * The startCommunication() login and endCommunication() logout, and methods between them don't log in/out, and - * it can expect faster operations. + * Start a communication scope with a shared authenticated session. + * + * Usually most methods login and logout before and after each database operation. + * By calling startCommunication() and endCommunication(), methods between them don't + * log in and out every time, and it can expect faster operations. + * + * Without a session cache, one authenticated session is kept for the duration of + * the current communication scope and discarded when endCommunication() is called. + * + * With a session cache, the session token is persisted beyond the current communication + * scope and reused across requests. If no cached token is available, a new session is + * created and stored for future reuse. + * * @throws Exception */ public function startCommunication(): void { - try { - if ($this->provider->login()) { - $this->provider->keepAuth = true; - } - } catch (Exception $e) { - $this->provider->keepAuth = false; - throw $e; - } + $this->provider->startCommunication(); } /** - * Finish a transaction which is a serial calling of any database operations, and logout. + * Finish a communication scope. + * + * Without a session cache, the authenticated session for the current communication + * scope is ended and the server session is logged out. + * + * With a session cache, the cached token's TTL is renewed if it still matches the + * token held by this instance. If another process has replaced the cached token in + * the meantime, only this instance's now-stale token is logged out, leaving the + * newer cached token intact. + * * @throws Exception */ public function endCommunication(): void { - $this->provider->keepAuth = false; - $this->provider->logout(); + $this->provider->endCommunication(); } /** @@ -425,4 +445,71 @@ public function setExcludeTimeStampInException(bool $value = true): void { $this->provider->excludeTimeStampInException = $value; } + + /** + * Controls whether failed Data API calls are automatically retried after session invalidation. + * + * When enabled and a call fails with error 952 (invalid token) or 112 (window missing), the + * current session is discarded, a new session is established, and the call is retried once. + * + * When a session cache is provided to the constructor, retry on token invalidation is always + * active regardless of this setting. This flag only has an effect when no session cache is + * configured. + * + * Warning: The retry runs in a fresh session. Any session-scoped state from the original session + * is lost — for example, global fields set before the retry will not carry over. + * @param bool $value + */ + public function setRetryOnAccessTokenInvalidation(bool $value = true): void + { + $this->provider->retryOnAccessTokenInvalidation = $value; + } + + /** + * Overrides the cache key used to store the FileMaker Data API session token. + * + * WARNING: Setting an incorrect cache key can cause session tokens to be shared + * across users or applications, which is a serious security risk. Do not use + * this method unless you fully understand the implications. + * + * The default cache key is a hashed representation of the following values: + * - The host name + * - The port number + * - The protocol (http or https) + * - The solution name + * - The user + * + * This default is sufficient for the vast majority of use cases. Only override + * this if you have a specific reason to do so. + * @param string $keyName The custom cache key name. + * @throws Exception If a session cache is not set, an exception is thrown. + */ + public function setSessionCacheKeyName(string $keyName): void + { + if ($this->provider->sessionCache === null) { + throw new Exception("setSessionCacheKeyName() requires a session cache to be configured via the constructor."); + } + $this->provider->sessionCache->setCacheKey($keyName); + } + + /** + * Overrides the time-to-live (TTL) of the cached FileMaker Data API session token. + * + * WARNING: Setting a TTL that exceeds the FileMaker Data API session timeout (15 minutes) + * will cause the library to use expired tokens, resulting in authentication failures. + * Do not use this method unless you fully understand the implications. + * + * The default TTL is 840 seconds (14 minutes), intentionally set one minute below the + * FileMaker Data API session timeout of 15 minutes to ensure the cached token is + * invalidated before it expires on the FileMaker Server. + * @param int $ttl Time-to-live in seconds. Defaults to 840 seconds (14 minutes). + * @throws Exception If a session cache is not set, an exception is thrown. + */ + public function setSessionCacheTtl(int $ttl = 840): void + { + if ($this->provider->sessionCache === null) { + throw new Exception("setSessionCacheTtl() requires a session cache to be configured via the constructor."); + } + $this->provider->sessionCache->setTtl($ttl); + } } diff --git a/src/SessionCache/AbstractSessionCache.php b/src/SessionCache/AbstractSessionCache.php new file mode 100644 index 0000000..d4a5be1 --- /dev/null +++ b/src/SessionCache/AbstractSessionCache.php @@ -0,0 +1,107 @@ +redis->get($this->cacheKey) ?? null; + * } + * + * public function set(string $value): bool + * { + * return $this->redis->setex($this->cacheKey, $this->ttl, $value); + * } + * + * public function delete(): bool + * { + * return $this->redis->del($this->cacheKey) > 0; + * } + * } + * + * @see SessionCacheInterface + */ +abstract class AbstractSessionCache implements SessionCacheInterface +{ + /** + * The cache key for the current session. + * + * Always set by the library via {@see self::setCacheKey()} before any cache + * operation is performed. Will not change during a single PHP request. + * Implementing classes should use this property directly in their + * {@see SessionCacheInterface::get()}, {@see SessionCacheInterface::set()}, + * and {@see SessionCacheInterface::delete()} implementations. + */ + protected string $cacheKey; + + /** + * The time-to-live in seconds for cached session tokens. + * + * Set by the library via {@see self::setTtl()} before any cache operation + * is performed, defaulting to the value provided at construction time. + * Will not change during a single PHP request. Implementing classes should + * use this property directly in their {@see SessionCacheInterface::set()} implementation. + * + */ + protected int $ttl; + + /** + * @param int $defaultTtl Default time-to-live in seconds for cached session tokens. + * Defaults to 840 seconds (14 minutes), reflecting the + * default FileMaker Data API session timeout. Adjust this + * value if your FileMaker Server is configured with a + * different session timeout. + */ + public function __construct(int $defaultTtl = 840) + { + $this->ttl = $defaultTtl; + } + + /** + * Sets the cache key for the current session. + * + * This method is called internally by the library and should not be called + * manually. The key will not change during a single PHP request. + * + * @param string $key The cache key to use for subsequent cache operations. + */ + final public function setCacheKey(string $key): void + { + $this->cacheKey = $key; + } + + /** + * Sets the time-to-live for cached session tokens. + * + * This method is called internally by the library and should not be called + * manually. The TTL will not change during a single PHP request. + * + * @param int $ttl Time-to-live in seconds for the cached session token. + */ + final public function setTtl(int $ttl): void + { + $this->ttl = $ttl; + } +} diff --git a/src/SessionCache/ApcuSessionCache.php b/src/SessionCache/ApcuSessionCache.php new file mode 100644 index 0000000..37d8dc9 --- /dev/null +++ b/src/SessionCache/ApcuSessionCache.php @@ -0,0 +1,85 @@ +cacheKey, $success); + return $success && is_string($value) ? $value : null; + } + + /** + * Persists a FileMaker Data API session token in APCu. + * + * @param string $value The FileMaker Data API session token to store. + * This is a sensitive credential and must be treated as such. + * @return bool True on success, false on failure. + */ + public function set(string $value): bool + { + return apcu_store($this->cacheKey, $value, $this->ttl); + } + + /** + * Deletes the cached FileMaker Data API session token. + * + * Returns false both when the key does not exist and when deletion fails. + * + * @return bool True on success, false if the key did not exist or deletion failed. + */ + public function delete(): bool + { + return apcu_delete($this->cacheKey); + } +} diff --git a/src/SessionCache/SessionCacheInterface.php b/src/SessionCache/SessionCacheInterface.php new file mode 100644 index 0000000..acd453c --- /dev/null +++ b/src/SessionCache/SessionCacheInterface.php @@ -0,0 +1,62 @@ +solution = rawurlencode($solution); $this->user = $user; @@ -260,7 +279,68 @@ public function __construct(string $solution, } } $this->fmDataSource = $fmDataSource; + $this->sessionCache = $sessionCache; $this->errorCode = -1; + if ($this->sessionCache !== null) { + $this->sessionCache->setCacheKey($this->cacheKey()); + } + } + + /** + * Start a communication scope with a shared authenticated session. + * + * Without a session cache, a new authenticated session is created and kept + * for the duration of the current communication scope. + * + * With a session cache, the cached session token is reused if available, + * avoiding a new login against the FileMaker Server. If no cached token is + * found, a new session is created and stored in the cache for future reuse. + * + * @throws Exception In case of any error, an exception arises. + */ + public function startCommunication(): void + { + try { + $this->keepAuth = $this->login(); + } catch (Exception $e) { + $this->keepAuth = false; + throw $e; + } + } + + /** + * Finish a communication scope. + * + * Without a session cache, the authenticated session is ended and the server + * session is logged out. + * + * With a session cache, if the token currently held by this instance matches + * the one in the cache, its TTL is renewed and the session is left alive. + * If another process has replaced the cached token in the meantime, this + * instance's now-stale token is considered orphaned and logged out at the + * server, leaving the newer cached token intact. + * + * @throws Exception In case of any error, an exception arises. + */ + public function endCommunication(): void + { + $this->keepAuth = false; + $this->resumeScopeAfterReauth = false; + + if ($this->sessionCache !== null && $this->accessToken !== null) { + if ($this->sessionCache->get() === $this->accessToken) { + // if the cache write fails, the token will expire naturally within 15 minutes. + // under sustained cache failures with high concurrency, orphaned tokens could + // approach FileMaker's session cap (error 953). + $this->sessionCache->set($this->accessToken); // renew TTL + $this->accessToken = null; + return; + } + // Mismatch: another worker replaced the cached token while we were active. + // Our token is an orphan — fall through and DELETE it, but don't touch the cache. + } + + $this->logout(); } /** @@ -490,6 +570,18 @@ public function login(): bool return true; } + if ($this->sessionCache !== null) { + $cached = $this->sessionCache->get(); + if ($cached !== null) { + $this->accessToken = $cached; + if ($this->resumeScopeAfterReauth) { + $this->keepAuth = true; + $this->resumeScopeAfterReauth = false; + } + return true; + } + } + if ($this->useOAuth) { $headers = [ "Content-Type" => "application/json", @@ -507,10 +599,17 @@ public function login(): bool $request = []; $request["fmDataSource"] = (!is_null($this->fmDataSource)) ? $this->fmDataSource : []; try { - $this->callRestAPI($params, false, "POST", $request, $headers); // Throw Exception + $this->callRestAPIWithoutRetry($params, false, "POST", $request, $headers); // Throw Exception $this->storeToProperties(); if ($this->httpStatus == 200 && $this->errorCode == 0) { $this->accessToken = $this->responseBody->response->token; + if ($this->sessionCache !== null) { + $this->sessionCache->set($this->accessToken); + } + if ($this->resumeScopeAfterReauth) { + $this->keepAuth = true; + $this->resumeScopeAfterReauth = false; + } return true; } } catch (Exception $e) { @@ -521,7 +620,10 @@ public function login(): bool } /** - * + * Tear down the current server-side session, unless either: + * - we're inside a multi-call scope (keepAuth), or + * - this token is the one currently shared via the persistent cache + * (in which case the cache owns its lifecycle). * @return void * @throws Exception In case of any error, an exception arises. * @ignore @@ -531,9 +633,20 @@ public function logout(): void if ($this->keepAuth) { return; } - $params = ["sessions" => $this->accessToken]; - $this->callRestAPI($params, true, "DELETE"); // Throw Exception - $this->accessToken = null; + if ($this->accessToken === null) { + return; + } + if ($this->sessionCache !== null && $this->sessionCache->get() === $this->accessToken) { + $this->accessToken = null; + return; + } + + try { + $params = ["sessions" => $this->accessToken]; + $this->callRestAPIWithoutRetry($params, true, "DELETE"); // Throw Exception + } finally { + $this->accessToken = null; + } } /** @@ -591,6 +704,8 @@ private function getOAuthIdentifier($provider): array|null } /** + * Sends a REST API request to the FileMaker Data API, retrying once on session invalidation if + * the retryOnAccessTokenInvalidation property is enabled. * @param array $params * @param bool $isAddToken * @param string $method @@ -599,7 +714,9 @@ private function getOAuthIdentifier($provider): array|null * @param bool $isSystem for Metadata * @param string|null|false $directPath * @return void - * @throws Exception In case of any error, an exception arises. + * @throws Exception In case of any error, an exception arises. If a retry was attempted, + * the original exception is available via getPrevious(). + * @see callRestAPIWithoutRetry() To bypass retry logic entirely. * @ignore */ public function callRestAPI(array $params, @@ -609,6 +726,69 @@ public function callRestAPI(array $params, array|null $addHeader = null, bool $isSystem = false, string|null|false $directPath = null): void + { + $firstAttempt = null; + try { + $this->callRestAPIWithoutRetry($params, $isAddToken, $method, $request, $addHeader, $isSystem, $directPath); + } catch (Exception $e) { + $firstAttempt = $e; + } + + if (!$this->shouldRetryOnTokenError()) { + if ($firstAttempt !== null) { + throw $firstAttempt; + } + return; + } + + // Token rejected by the server. Clear the cache before re-login so racing workers + // don't re-adopt the dead token; preserve the in-process scope across the re-login. + if ($this->sessionCache !== null) { + $this->sessionCache->delete(); + } + $resumeScope = $this->keepAuth; + $this->accessToken = null; + $this->keepAuth = false; + try { + if (!$this->login()) { + $this->resumeScopeAfterReauth = $resumeScope; + return; + } + } catch (Exception $e) { + $this->resumeScopeAfterReauth = $resumeScope; + throw new Exception($e->getMessage(), $e->getCode(), $firstAttempt); + } + + // The login was successful here — retry the original call + $this->keepAuth = $resumeScope; + try { + $this->callRestAPIWithoutRetry($params, $isAddToken, $method, $request, $addHeader, $isSystem, $directPath); + } catch (Exception $e) { + throw new Exception($e->getMessage(), $e->getCode(), $firstAttempt); + } + } + + /** + * Sends a REST API request to the FileMaker Data API without any retry logic. + * @param array $params + * @param bool $isAddToken + * @param string $method + * @param string|array|null $request + * @param array|null $addHeader + * @param bool $isSystem for Metadata + * @param string|null|false $directPath + * @return void + * @throws Exception In case of any error, an exception arises. + * @see callRestAPI() For the recommended entry point with automatic retry on session invalidation. + * @ignore + */ + protected function callRestAPIWithoutRetry(array $params, + bool $isAddToken, + string $method = 'GET', + string|array|null $request = null, + array|null $addHeader = null, + bool $isSystem = false, + string|null|false $directPath = null): void { $methodLower = strtolower($method); $url = $this->getURL($params, $request, $methodLower, $isSystem, $directPath); @@ -834,6 +1014,39 @@ public function debugOutput(bool $isReturnValue = false): string return ""; } + /** + * @return bool + * @ignore + */ + private function shouldRetryOnTokenError(): bool + { + if ($this->sessionCache === null && !$this->retryOnAccessTokenInvalidation) { + return false; + } + + $errorCode = $this->extractErrorCode(); + // Error code 952 - Invalid FileMaker Data API token + // Occurs when the access token has expired or been invalidated. + // Error code 112 - "Window is missing" (likely unintentional by the FileMaker Data API) + // Reproducible when a Data API session is closed externally mid-request, + // producing a spurious "window missing" error rather than a proper auth failure. + return $errorCode === 952 || $errorCode === 112; + } + + /** + * @return int + * @ignore + */ + private function extractErrorCode(): int + { + $errorCode = -1; + if (is_object($this->responseBody) && property_exists($this->responseBody, 'messages')) { + $result = $this->responseBody->messages[0]; + $errorCode = property_exists($result, 'code') ? intval($result->code) : -1; + } + return $errorCode; + } + /** * @param array $value * @return string @@ -914,4 +1127,19 @@ private function _createCurlHandle(string $url): CurlHandle } return $ch; } + + private function cacheKey(): string + { + $data = [ + $this->user, + $this->solution, + (string)$this->port, + $this->host, + $this->protocol, + ]; + + $hash = hash('sha256', implode("\0", $data)); + + return "fm_token_$hash"; + } } diff --git a/src/Supporting/FileMakerLayout.php b/src/Supporting/FileMakerLayout.php index 5bb0c32..c602d74 100644 --- a/src/Supporting/FileMakerLayout.php +++ b/src/Supporting/FileMakerLayout.php @@ -42,25 +42,42 @@ public function __construct(CommunicationProvider|null $restAPI, } /** - * Start a transaction which is a serial calling of any database operations - * and log in with the target layout. + * Start a communication scope with a shared authenticated session. + * + * Usually most methods login and logout before and after each database operation. + * By calling startCommunication() and endCommunication(), methods between them don't + * log in and out every time, and it can expect faster operations. + * + * Without a session cache, one authenticated session is kept for the duration of + * the current communication scope and discarded when endCommunication() is called. + * + * With a session cache, the session token is persisted beyond the current communication + * scope and reused across requests. If no cached token is available, a new session is + * created and stored for future reuse. + * * @throws Exception */ public function startCommunication(): void { - if ($this->restAPI->login()) { - $this->restAPI->keepAuth = true; - } + $this->restAPI->startCommunication(); } /** - * Finish a transaction which is a serial calling of any database operations, and logout. + * Finish a communication scope. + * + * Without a session cache, the authenticated session for the current communication + * scope is ended and the server session is logged out. + * + * With a session cache, the cached token's TTL is renewed if it still matches the + * token held by this instance. If another process has replaced the cached token in + * the meantime, only this instance's now-stale token is logged out, leaving the + * newer cached token intact. + * * @throws Exception */ public function endCommunication(): void { - $this->restAPI->keepAuth = false; - $this->restAPI->logout(); + $this->restAPI->endCommunication(); } /** diff --git a/test/TestProvider.php b/test/TestProvider.php index 4f02327..e5e0d06 100644 --- a/test/TestProvider.php +++ b/test/TestProvider.php @@ -9,6 +9,7 @@ namespace INTERMediator\FileMakerServer\RESTAPI\Supporting; use Exception; +use INTERMediator\FileMakerServer\RESTAPI\SessionCache\AbstractSessionCache; class TestProvider extends CommunicationProvider { @@ -22,17 +23,19 @@ class TestProvider extends CommunicationProvider * @param string|null $port * @param string|null $protocol * @param array|null $fmDataSource + * @param AbstractSessionCache|null $sessionCache * @ignore */ - public function __construct(string $solution, - string $user, - string|null $password, - string|null $host = null, - string|null $port = null, - string|null $protocol = null, - array|null $fmDataSource = null) + public function __construct(string $solution, + string $user, + string|null $password, + string|null $host = null, + string|null $port = null, + string|null $protocol = null, + array|null $fmDataSource = null, + AbstractSessionCache|null $sessionCache = null) { - parent::__construct($solution, $user, $password, $host, $port, $protocol, $fmDataSource); + parent::__construct($solution, $user, $password, $host, $port, $protocol, $fmDataSource, $sessionCache); $this->buildResponses(); } @@ -49,13 +52,13 @@ public function __construct(string $solution, * @throws Exception In case of any error, an exception arises. * @ignore */ - public function callRestAPI(array $params, - bool $isAddToken, - string $method = 'GET', - array|null|string $request = null, - array|null $addHeader = null, - bool $isSystem = false, - string|null|false $directPath = null): void + protected function callRestAPIWithoutRetry(array $params, + bool $isAddToken, + string $method = 'GET', + array|null|string $request = null, + array|null $addHeader = null, + bool $isSystem = false, + string|null|false $directPath = null): void { $methodLower = strtolower($method); $url = $this->getURL($params, $request, $methodLower);