$value, 'until' => $GLOBALS['idle_token_now'] + $ttl, 'ttl' => $ttl]; return true; } public static function delete($key): bool { unset(self::$entries[$key]); return true; } } } namespace app\common\cache { function time(): int { return $GLOBALS['idle_token_now']; } } namespace app\api\service { function time(): int { return $GLOBALS['idle_token_now']; } } namespace app\common\service { final class JsonService { public static function fail($msg, $data = [], $code = 0, $show = 1): array { return compact('msg', 'data', 'code', 'show'); } } } namespace { error_reporting(E_ALL & ~E_DEPRECATED); function app($name = null) { $app = \think\Container::getInstance(); return $name === null ? $app : $app->make($name); } function create_token($userId): string { static $sequence = 0; return 'token-' . $userId . '-' . ++$sequence; } $repo = getenv('TOKEN_SITE_REPO') ?: dirname(__DIR__, 3) . '/melbet_sport-api'; $stage = getenv('TOKEN_SITE_STAGE') ?: $repo; $loader = require $repo . '/vendor/autoload.php'; $loader->setPsr4('app\\', [$stage . '/app/', $repo . '/app/']); $app = new \think\App($repo . '/'); \think\Container::setInstance($app); $db = new \think\DbManager(); $db->setConfig(['default' => 'isolated', 'connections' => ['isolated' => [ 'type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_', 'fields_strict' => true, 'debug' => false, ]], 'auto_timestamp' => false]); $app->instance(\think\DbManager::class, $db); $app->instance('log', new \Psr\Log\NullLogger()); $db->execute('CREATE TABLE bot_user_session (id INTEGER PRIMARY KEY,user_id TEXT,token TEXT,expire_time INTEGER,create_time INTEGER,update_time INTEGER)'); $db->execute('CREATE TABLE bot_users (id INTEGER PRIMARY KEY,user_id TEXT,account TEXT,avatar TEXT,"from" INTEGER,recharge_channel_group_id INTEGER,is_banned INTEGER DEFAULT 0,delete_time TEXT,created_at TEXT,updated_at TEXT)'); \think\facade\Db::table('bot_users')->insert(['id' => 1, 'user_id' => '100001', 'account' => 'member', 'avatar' => '', 'from' => 1, 'recharge_channel_group_id' => 1]); $GLOBALS['idle_token_now'] = 1792912735; $start = $GLOBALS['idle_token_now']; $checks = 0; function check($actual, $expected, string $message): void { global $checks; ++$checks; if ($actual !== $expected) throw new \RuntimeException($message . ': actual=' . json_encode($actual) . ' expected=' . json_encode($expected)); } function row(string $token): ?array { return \think\facade\Db::table('bot_user_session')->where('token', $token)->find(); } class TokenRequest { public $userInfo; public $userId; public object $controllerObject; public function __construct(private ?string $token, bool $public = false) { $this->controllerObject = new class($public) { public function __construct(private bool $public) {} public function isNotNeedLogin(): bool { return $this->public; } }; } public function header($name) { return $this->token; } } function authenticate(?string $token, bool $public = false): array { $request = new TokenRequest($token, $public); return (new \app\api\http\middleware\LoginMiddleware())->handle($request, static fn($r) => ['code' => 1, 'user_id' => $r->userId, 'userInfo' => $r->userInfo]); } $cache = new \app\common\cache\UserTokenCache(); $info = \app\api\service\UserTokenService::setToken('100001'); $token = $info['token']; check($info['expire_time'], $start + 86400, 'Login issues 24-hour token'); check(row($token)['update_time'], $start, 'Login records the activity clock'); check(\think\facade\Cache::$entries['token_user_' . $token]['ttl'], 86400, 'Cache TTL equals the effective expiry'); \think\facade\Cache::set('token_user_' . $token, ['token' => $token, 'user_id' => 'wrong-user', 'expire_time' => $start + 7 * 86400], 7 * 86400); $corrected = $cache->getUserInfo($token); check((string)$corrected['user_id'], '100001', 'A stale cached lookup hint cannot change session identity'); check($corrected['expire_time'], $start + 86400, 'Cached seven-day deadline cannot replace the database inactivity deadline'); $GLOBALS['idle_token_now'] = $start + 3600; $unchanged = row($token); $readOnly = $cache->getUserInfo($token); check($readOnly['expire_time'], $start + 86400, 'Read-only crypto/WS lookup preserves deadline'); check(row($token), $unchanged, 'Cache lookup never renews activity'); \think\facade\Cache::$entries = []; check($cache->getUserInfo($token)['expire_time'], $start + 86400, 'Clearing cache does not restart the inactivity window'); check(\think\facade\Cache::$entries['token_user_' . $token]['ttl'], 82800, 'Reloaded cache only gets remaining lifetime'); $authenticated = authenticate($token); check($authenticated['code'], 1, 'Authenticated request succeeds'); check($authenticated['userInfo']['expire_time'], $start + 3600 + 86400, 'Every valid request slides deadline by a full day'); check(row($token)['update_time'], $start + 3600, 'Activity saved in database'); check(\think\facade\Cache::$entries['token_user_' . $token]['ttl'], 86400, 'Renewal refreshes cache TTL'); $GLOBALS['idle_token_now'] = $start + 25 * 3600 - 1; check(authenticate($token, true)['code'], 1, 'Valid authenticated polling on a public endpoint also renews'); check(row($token)['expire_time'], $GLOBALS['idle_token_now'] + 86400, 'Active users stay logged in beyond an absolute login day'); $lastActivity = $GLOBALS['idle_token_now']; $GLOBALS['idle_token_now'] = $lastActivity + 86400; $expiredBefore = row($token); \think\facade\Cache::set('token_user_' . $token, $info + ['unexpected' => 'old'], 7 * 86400); check(authenticate($token)['code'], 401, 'Exactly 24 hours without activity rejects authentication'); check(\app\api\service\UserTokenService::overtimeToken($token), false, 'Expired token cannot be renewed directly'); check(row($token), $expiredBefore, 'Expired request cannot revive the session'); check($cache->getUserInfo($token), false, 'Stale cache cannot bypass expiry'); check(authenticate($token, true)['user_id'], 0, 'Public endpoint treats invalid token as anonymous'); check(authenticate(null)['code'], 401, 'Protected endpoint still requires a token'); check(authenticate(null, true)['code'], 1, 'Anonymous public access stays available'); // Interleave real logout/new-login between identity lookup and the conditional renewal. $raceMode = null; $raceToken = null; $replacementToken = null; $db->event('before_find', static function ($query) use (&$raceMode, &$raceToken, &$replacementToken) { if ($query->getTable() !== 'bot_users' || $raceMode === null) return; $mode = $raceMode; $raceMode = null; ++$GLOBALS['idle_token_now']; if ($mode === 'logout') \app\api\service\UserTokenService::expireToken($raceToken); else $replacementToken = \app\api\service\UserTokenService::setToken('100001')['token']; }); foreach (['logout', 'replace'] as $mode) { $GLOBALS['idle_token_now'] += 60; $raceToken = \app\api\service\UserTokenService::setToken('100001')['token']; $GLOBALS['idle_token_now'] += 60; $raceMode = $mode; check(authenticate($raceToken)['code'], 401, 'Concurrent ' . $mode . ' wins over a stale renewal'); if ($mode === 'logout') check(row($raceToken)['expire_time'], $GLOBALS['idle_token_now'], 'Concurrent logout is not overwritten'); else { check(row($raceToken), null, 'Old token is not restored after a concurrent new login'); check(row($replacementToken)['expire_time'], $GLOBALS['idle_token_now'] + 86400, 'Replacement token retains its deadline'); } } // Seven-day legacy expiration cannot hide more than 24h inactivity. $GLOBALS['idle_token_now'] = $start + 3 * 86400; \think\facade\Db::table('bot_user_session')->insert(['id' => 2, 'user_id' => '100001', 'token' => 'legacy', 'expire_time' => $start + 7 * 86400, 'create_time' => $start, 'update_time' => $start]); $legacy = row('legacy'); check(authenticate('legacy')['code'], 401, 'Legacy long expiry still obeys the last-activity cutoff'); check(row('legacy'), $legacy, 'Legacy expired sessions are not silently reactivated'); foreach ([null, 0] as $index => $updateTime) { $fallbackToken = 'fallback-' . $index; \think\facade\Db::table('bot_user_session')->insert(['user_id' => '100001', 'token' => $fallbackToken, 'expire_time' => $GLOBALS['idle_token_now'] + 7 * 86400, 'create_time' => $GLOBALS['idle_token_now'] - 3600, 'update_time' => $updateTime]); check($cache->getUserInfo($fallbackToken)['expire_time'], $GLOBALS['idle_token_now'] + 82800, 'Missing activity falls back to creation time'); check(authenticate($fallbackToken)['userInfo']['expire_time'], $GLOBALS['idle_token_now'] + 86400, 'Valid legacy session can renew without a new login'); } $fresh = \app\api\service\UserTokenService::setToken('100001'); $newToken = $fresh['token']; \think\facade\Cache::set('token_user_' . $token, $info, 86400); check($cache->getUserInfo($token), false, 'Old token remains invalid after a new login even with stale cache'); $GLOBALS['idle_token_now'] += 60; check(\app\api\service\UserTokenService::expireToken($newToken), true, 'Logout revokes current token'); $revoked = row($newToken); check(authenticate($newToken)['code'], 401, 'Logout token cannot authenticate'); check(row($newToken), $revoked, 'Logout token cannot be resurrected by renewal'); check(\app\api\service\UserTokenService::overtimeToken('does-not-exist'), false, 'Missing session fails without a null dereference'); $fresh = \app\api\service\UserTokenService::setToken('100001'); $newToken = $fresh['token']; $future = $GLOBALS['idle_token_now'] + 10; \think\facade\Db::table('bot_user_session')->where('token', $newToken)->update(['update_time' => $future, 'expire_time' => $future + 86400]); $laterRequest = row($newToken); check(authenticate($newToken)['code'], 1, 'Older concurrent request still sees the valid renewed session'); check(row($newToken), $laterRequest, 'An older renewal never moves deadline backwards'); \think\facade\Db::table('bot_users')->where('user_id', '100001')->update(['is_banned' => 1]); $beforeBanned = row($newToken); check($cache->getUserInfo($newToken), false, 'Read-only socket/crypto cache lookup also rejects a banned account'); check(authenticate($newToken)['code'], 401, 'Banned account cannot keep a cached token alive indefinitely'); check(row($newToken), $beforeBanned, 'Banned account does not count as valid activity'); \think\facade\Db::table('bot_users')->where('user_id', '100001')->update(['is_banned' => 0]); \think\facade\Db::table('bot_users')->where('user_id', '100001')->update(['delete_time' => time()]); $beforeMissingUser = row($newToken); check(empty($cache->getUserInfo($newToken)), true, 'Deleted identity cannot survive through a stale cached profile'); check(authenticate($newToken)['code'], 401, 'Deleted user cannot authenticate through a cached profile'); check(row($newToken), $beforeMissingUser, 'Failed identity check does not count as activity'); \think\facade\Db::table('bot_users')->insert(['id' => 2, 'user_id' => '100002', 'account' => '', 'avatar' => '', 'from' => 2, 'recharge_channel_group_id' => 1]); $guest = \app\api\service\UserTokenService::setToken('100002'); check($guest['from'], 2, 'Guest identity remains a guest'); check($guest['expire_time'], $GLOBALS['idle_token_now'] + 86400, 'Guest token also receives a sliding-day lifetime'); check(authenticate($guest['token'])['code'], 1, 'Token timeout change does not disable existing guest authentication'); echo 'PASS ' . $checks . " member idle-token assertions (isolated SQLite and memory cache; no live sessions)\n"; }