diff --git a/core/components/minishop3/src/Controllers/Api/Web/AuthorizedCustomerTrait.php b/core/components/minishop3/src/Controllers/Api/Web/AuthorizedCustomerTrait.php index 84c53df4..6dae593e 100644 --- a/core/components/minishop3/src/Controllers/Api/Web/AuthorizedCustomerTrait.php +++ b/core/components/minishop3/src/Controllers/Api/Web/AuthorizedCustomerTrait.php @@ -3,6 +3,7 @@ namespace MiniShop3\Controllers\Api\Web; use MiniShop3\Model\msCustomer; +use MiniShop3\Services\TokenService; /** * Trait for Web API controllers that need to resolve the authorized customer. @@ -26,19 +27,22 @@ protected function getAuthorizedCustomer(): ?msCustomer // Method 1: Try API token $tokenString = $_REQUEST['ms3_token'] ?? $_SESSION['ms3']['customer_token'] ?? ''; + $tokenPresented = $tokenString !== ''; - if (!empty($tokenString)) { - $tokenObj = $this->modx->getObject(\MiniShop3\Model\msCustomerToken::class, [ - 'token' => $tokenString, - 'type' => \MiniShop3\Model\msCustomerToken::TYPE_API - ]); + if ($tokenPresented) { + /** @var TokenService $tokenService */ + $tokenService = $this->modx->services->get('ms3_token_service'); + $resolved = $tokenService->resolveApiToken($tokenString); - if ($tokenObj && !$tokenObj->isExpired()) { - $customer = $this->modx->getObject(msCustomer::class, $tokenObj->get('customer_id')); + if ($resolved['reason'] === 'ok') { + $customer = $this->modx->getObject(msCustomer::class, $resolved['token']->get('customer_id')); if ($customer) { return $customer; } } + + // Explicit token was rejected — do not fall back to session customer_id. + return null; } // Method 2: Fall back to session customer_id (consistent with TokenMiddleware) diff --git a/core/components/minishop3/src/Middleware/TokenMiddleware.php b/core/components/minishop3/src/Middleware/TokenMiddleware.php index 46a0c6cd..1348720e 100644 --- a/core/components/minishop3/src/Middleware/TokenMiddleware.php +++ b/core/components/minishop3/src/Middleware/TokenMiddleware.php @@ -76,34 +76,19 @@ public function handle(array $params) session_start(); } - // For non-public routes: check session first - if (!$isPublic && !empty($_SESSION['ms3']['customer_id'])) { - $customer = $this->modx->getObject(\MiniShop3\Model\msCustomer::class, $_SESSION['ms3']['customer_id']); - if ($customer) { - return null; - } - } + /** @var TokenService $tokenService */ + $tokenService = $this->modx->services->get('ms3_token_service'); // Resolve token from multiple sources $token = $this->resolveToken(); + // If a token is present, always validate it (do not skip via session bypass). if (!empty($token)) { - // Validate token in DB - $tokenObj = $this->modx->getObject(\MiniShop3\Model\msCustomerToken::class, [ - 'token' => $token, - 'type' => \MiniShop3\Model\msCustomerToken::TYPE_API - ]); - - if ($tokenObj) { - // Auto-renew expired token - if ($tokenObj->isExpired()) { - $ttl = (int)$this->modx->getOption('ms3_customer_token_ttl', null, 604800); - $newExpiresAt = date('Y-m-d H:i:s', time() + $ttl); - $tokenObj->set('expires_at', $newExpiresAt); - $tokenObj->save(); - } + $resolved = $tokenService->resolveApiToken($token); + + if ($resolved['reason'] === 'ok') { + $tokenObj = $resolved['token']; - // Save to session if (!isset($_SESSION['ms3'])) { $_SESSION['ms3'] = []; } @@ -111,30 +96,39 @@ public function handle(array $params) $_SESSION['ms3']['customer_id'] = $tokenObj->get('customer_id'); $_SESSION['ms3']['customer_token_expires'] = strtotime($tokenObj->get('expires_at')); - // Refresh cookie CookieHelper::setTokenCookie($this->modx, $token); - - // Ensure $_REQUEST has the token for controllers $_REQUEST['ms3_token'] = $token; return null; } - // Token found in request but not in DB — invalid - if (!$isPublic) { + $this->clearClientTokenState(); + + if ($resolved['reason'] === 'expired') { + if (!$isPublic) { + $this->modx->log( + modX::LOG_LEVEL_INFO, + '[TokenMiddleware] Rejected expired API token: ' . substr($token, 0, 16) . '...' + ); + return Response::error('ms3_err_token_expired', HttpStatus::UNAUTHORIZED); + } + } elseif (!$isPublic) { $this->modx->log( modX::LOG_LEVEL_ERROR, - "[TokenMiddleware] Token not found in database. Token: " . substr($token, 0, 16) . "..." + '[TokenMiddleware] Token not found in database. Token: ' . substr($token, 0, 16) . '...' ); return Response::error('ms3_err_token_invalid', HttpStatus::UNAUTHORIZED); } + } elseif (!$isPublic && !empty($_SESSION['ms3']['customer_id'])) { + // No token in request: allow existing session customer (browser session). + $customer = $this->modx->getObject(\MiniShop3\Model\msCustomer::class, $_SESSION['ms3']['customer_id']); + if ($customer) { + return null; + } } // No valid token found if (!$isPublic) { - // Auto-create token for first visit - /** @var TokenService $tokenService */ - $tokenService = $this->modx->services->get('ms3_token_service'); $result = $tokenService->generateCustomerToken(); if (!empty($result['token'])) { @@ -148,6 +142,20 @@ public function handle(array $params) return null; } + /** + * Clear cookie, request and session token identity after reject/expiry. + */ + private function clearClientTokenState(): void + { + CookieHelper::clearTokenCookie($this->modx); + unset( + $_REQUEST['ms3_token'], + $_SESSION['ms3']['customer_token'], + $_SESSION['ms3']['customer_token_expires'], + $_SESSION['ms3']['customer_id'] + ); + } + /** * Resolve token from request sources * diff --git a/core/components/minishop3/src/Model/msCustomerToken.php b/core/components/minishop3/src/Model/msCustomerToken.php index f1cf102f..0bfdecbc 100644 --- a/core/components/minishop3/src/Model/msCustomerToken.php +++ b/core/components/minishop3/src/Model/msCustomerToken.php @@ -2,6 +2,7 @@ namespace MiniShop3\Model; +use MiniShop3\Utils\ApiTokenExpiry; use xPDO\Om\xPDOObject; use xPDO\Om\xPDOSimpleObject; @@ -37,8 +38,7 @@ class msCustomerToken extends xPDOSimpleObject */ public function isExpired() { - $expiresAt = strtotime($this->get('expires_at')); - return $expiresAt < time(); + return ApiTokenExpiry::isExpiresAtBefore((string) $this->get('expires_at'), time()); } /** diff --git a/core/components/minishop3/src/Services/Customer/AuthManager.php b/core/components/minishop3/src/Services/Customer/AuthManager.php index 8a80bbac..8ecedc1d 100644 --- a/core/components/minishop3/src/Services/Customer/AuthManager.php +++ b/core/components/minishop3/src/Services/Customer/AuthManager.php @@ -217,8 +217,7 @@ public function validateToken(string $tokenString, string $type = 'api'): ?msCus return null; } - $expiresAt = strtotime($token->get('expires_at')); - if ($expiresAt < time()) { + if ($token->isExpired()) { $this->modx->log( modX::LOG_LEVEL_DEBUG, "[AuthManager] Token expired: {$tokenString}" diff --git a/core/components/minishop3/src/Services/TokenService.php b/core/components/minishop3/src/Services/TokenService.php index caf850bb..d03f7772 100644 --- a/core/components/minishop3/src/Services/TokenService.php +++ b/core/components/minishop3/src/Services/TokenService.php @@ -103,6 +103,43 @@ public function generateCustomerToken(?int $ttl = null): array ]; } + /** + * Resolve an API token from the database. + * + * Expired tokens are removed (same policy as AuthManager::validateToken). + * Does not extend TTL and does not keep the same token string alive. + * + * @return array{token: ?msCustomerToken, reason: 'ok'|'missing'|'expired'} + */ + public function resolveApiToken(string $tokenString): array + { + if ($tokenString === '') { + return ['token' => null, 'reason' => 'missing']; + } + + /** @var msCustomerToken|null $tokenObj */ + $tokenObj = $this->modx->getObject(msCustomerToken::class, [ + 'token' => $tokenString, + 'type' => msCustomerToken::TYPE_API, + ]); + + if (!$tokenObj) { + return ['token' => null, 'reason' => 'missing']; + } + + if ($tokenObj->isExpired()) { + $this->modx->log( + modX::LOG_LEVEL_INFO, + '[TokenService] API token expired, removing. Token: ' . substr($tokenString, 0, 16) . '...' + ); + $tokenObj->remove(); + + return ['token' => null, 'reason' => 'expired']; + } + + return ['token' => $tokenObj, 'reason' => 'ok']; + } + /** * Resolve existing token or create new one * @@ -111,34 +148,34 @@ public function generateCustomerToken(?int $ttl = null): array * 2. Cookie token → verify in DB (msCustomerToken type=api), restore session, return * 3. Generate new token → set cookie + session, return * + * Expired cookie tokens are discarded (not auto-renewed) and a new token is created. + * * @return string Token string */ public function resolveOrCreateToken(): string { - // 1. Check session + // 1. Check session (re-validate against DB — do not trust session TTL alone) $sessionToken = $this->getCustomerToken(); if ($sessionToken) { - CookieHelper::setTokenCookie($this->modx, $sessionToken); - return $sessionToken; + $resolved = $this->resolveApiToken($sessionToken); + if ($resolved['reason'] === 'ok') { + CookieHelper::setTokenCookie($this->modx, $sessionToken); + return $sessionToken; + } + + $this->clearCustomerToken(); + CookieHelper::clearTokenCookie($this->modx); + unset($_SESSION['ms3']['customer_id']); } // 2. Check cookie $cookieToken = CookieHelper::getTokenFromCookie(); if (!empty($cookieToken)) { - $tokenObj = $this->modx->getObject(msCustomerToken::class, [ - 'token' => $cookieToken, - 'type' => msCustomerToken::TYPE_API, - ]); + $resolved = $this->resolveApiToken($cookieToken); - if ($tokenObj) { - // Auto-renew expired token - if ($tokenObj->isExpired()) { - $ttl = (int)$this->modx->getOption('ms3_customer_token_ttl', null, 604800); - $tokenObj->set('expires_at', date('Y-m-d H:i:s', time() + $ttl)); - $tokenObj->save(); - } + if ($resolved['reason'] === 'ok') { + $tokenObj = $resolved['token']; - // Restore session if (!isset($_SESSION['ms3'])) { $_SESSION['ms3'] = []; } @@ -150,11 +187,14 @@ public function resolveOrCreateToken(): string $_SESSION['ms3']['customer_id'] = $customerId; } - // Refresh cookie TTL CookieHelper::setTokenCookie($this->modx, $cookieToken); return $cookieToken; } + + if ($resolved['reason'] === 'expired' || $resolved['reason'] === 'missing') { + CookieHelper::clearTokenCookie($this->modx); + } } // 3. Generate new token diff --git a/core/components/minishop3/src/Utils/ApiTokenExpiry.php b/core/components/minishop3/src/Utils/ApiTokenExpiry.php new file mode 100644 index 00000000..653bdaf3 --- /dev/null +++ b/core/components/minishop3/src/Utils/ApiTokenExpiry.php @@ -0,0 +1,24 @@ +