Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down
70 changes: 39 additions & 31 deletions core/components/minishop3/src/Middleware/TokenMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,65 +76,59 @@ 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'] = [];
}
$_SESSION['ms3']['customer_token'] = $token;
$_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'])) {
Expand All @@ -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
*
Expand Down
4 changes: 2 additions & 2 deletions core/components/minishop3/src/Model/msCustomerToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace MiniShop3\Model;

use MiniShop3\Utils\ApiTokenExpiry;
use xPDO\Om\xPDOObject;
use xPDO\Om\xPDOSimpleObject;

Expand Down Expand Up @@ -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());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
72 changes: 56 additions & 16 deletions core/components/minishop3/src/Services/TokenService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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'] = [];
}
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions core/components/minishop3/src/Utils/ApiTokenExpiry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace MiniShop3\Utils;

/**
* Pure helpers for API token expiry checks (no MODX/xPDO).
*/
final class ApiTokenExpiry
{
/**
* Whether expires_at is strictly before $timestamp.
*
* Invalid/unparseable dates are treated as expired.
*/
public static function isExpiresAtBefore(string $expiresAt, int $timestamp): bool
{
$expires = strtotime($expiresAt);
if ($expires === false) {
return true;
}

return $expires < $timestamp;
}
}
67 changes: 67 additions & 0 deletions core/components/minishop3/tests/ApiTokenExpiryPolicyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

/**
* Static checks for API token expiry policy (without MODX).
*
* Expired TYPE_API tokens must be rejected/removed — never silently extended
* while keeping the same token string (issue #371).
*
* Run: php tests/ApiTokenExpiryPolicyTest.php
*/

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use MiniShop3\Utils\ApiTokenExpiry;

$fail = static function (string $message): never {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
};

$now = 1_700_000_000;

if (!ApiTokenExpiry::isExpiresAtBefore(date('Y-m-d H:i:s', $now - 1), $now)) {
$fail('past expires_at must be expired');
}

if (ApiTokenExpiry::isExpiresAtBefore(date('Y-m-d H:i:s', $now + 1), $now)) {
$fail('future expires_at must not be expired');
}

if (ApiTokenExpiry::isExpiresAtBefore(date('Y-m-d H:i:s', $now), $now)) {
$fail('expires_at equal to now must not be expired (strict <)');
}

if (!ApiTokenExpiry::isExpiresAtBefore('not-a-date', $now)) {
$fail('unparseable expires_at must be treated as expired');
}

$middleware = file_get_contents(dirname(__DIR__) . '/src/Middleware/TokenMiddleware.php');
if ($middleware === false) {
$fail('cannot read TokenMiddleware.php');
}
if (str_contains($middleware, 'Auto-renew expired token')) {
$fail('TokenMiddleware must not auto-renew expired tokens');
}
if (!str_contains($middleware, 'ms3_err_token_expired')) {
$fail('TokenMiddleware must reject expired tokens with ms3_err_token_expired');
}
if (!str_contains($middleware, 'clearClientTokenState')) {
$fail('TokenMiddleware must clear full client token state on reject');
}

$tokenServiceSource = file_get_contents(dirname(__DIR__) . '/src/Services/TokenService.php');
if ($tokenServiceSource === false) {
$fail('cannot read TokenService.php');
}
if (str_contains($tokenServiceSource, 'Auto-renew expired token')) {
$fail('TokenService must not auto-renew expired tokens');
}
if (!str_contains($tokenServiceSource, 'function resolveApiToken')) {
$fail('TokenService must expose resolveApiToken as the canonical expiry gate');
}

fwrite(STDOUT, "OK ApiTokenExpiryPolicyTest\n");
exit(0);