member_token_idle_timeout.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. <?php
  2. namespace think\facade {
  3. final class Cache {
  4. public static array $entries = [];
  5. public static function get($key, $default = null) {
  6. $entry = self::$entries[$key] ?? null;
  7. if (!$entry || $entry['until'] <= $GLOBALS['idle_token_now']) { unset(self::$entries[$key]); return $default; }
  8. return $entry['value'];
  9. }
  10. public static function set($key, $value, $ttl): bool { self::$entries[$key] = ['value' => $value, 'until' => $GLOBALS['idle_token_now'] + $ttl, 'ttl' => $ttl]; return true; }
  11. public static function delete($key): bool { unset(self::$entries[$key]); return true; }
  12. }
  13. }
  14. namespace app\common\cache { function time(): int { return $GLOBALS['idle_token_now']; } }
  15. namespace app\api\service { function time(): int { return $GLOBALS['idle_token_now']; } }
  16. namespace app\common\service {
  17. final class JsonService { public static function fail($msg, $data = [], $code = 0, $show = 1): array { return compact('msg', 'data', 'code', 'show'); } }
  18. }
  19. namespace {
  20. error_reporting(E_ALL & ~E_DEPRECATED);
  21. function app($name = null) { $app = \think\Container::getInstance(); return $name === null ? $app : $app->make($name); }
  22. function create_token($userId): string { static $sequence = 0; return 'token-' . $userId . '-' . ++$sequence; }
  23. $repo = getenv('TOKEN_SITE_REPO') ?: dirname(__DIR__, 3) . '/melbet_sport-api';
  24. $stage = getenv('TOKEN_SITE_STAGE') ?: $repo;
  25. $loader = require $repo . '/vendor/autoload.php';
  26. $loader->setPsr4('app\\', [$stage . '/app/', $repo . '/app/']);
  27. $app = new \think\App($repo . '/'); \think\Container::setInstance($app);
  28. $db = new \think\DbManager();
  29. $db->setConfig(['default' => 'isolated', 'connections' => ['isolated' => [
  30. 'type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_', 'fields_strict' => true, 'debug' => false,
  31. ]], 'auto_timestamp' => false]);
  32. $app->instance(\think\DbManager::class, $db); $app->instance('log', new \Psr\Log\NullLogger());
  33. $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)');
  34. $db->execute('CREATE TABLE bot_users (id INTEGER PRIMARY KEY,user_id TEXT,account TEXT,avatar TEXT,"from" INTEGER,recharge_channel_group_id INTEGER,delete_time TEXT,created_at TEXT,updated_at TEXT)');
  35. \think\facade\Db::table('bot_users')->insert(['id' => 1, 'user_id' => '100001', 'account' => 'member', 'avatar' => '', 'from' => 1, 'recharge_channel_group_id' => 1]);
  36. $GLOBALS['idle_token_now'] = 1792912735;
  37. $start = $GLOBALS['idle_token_now'];
  38. $checks = 0;
  39. function check($actual, $expected, string $message): void {
  40. global $checks; ++$checks;
  41. if ($actual !== $expected) throw new \RuntimeException($message . ': actual=' . json_encode($actual) . ' expected=' . json_encode($expected));
  42. }
  43. function row(string $token): ?array { return \think\facade\Db::table('bot_user_session')->where('token', $token)->find(); }
  44. class TokenRequest {
  45. public $userInfo; public $userId;
  46. public object $controllerObject;
  47. public function __construct(private ?string $token, bool $public = false) {
  48. $this->controllerObject = new class($public) { public function __construct(private bool $public) {} public function isNotNeedLogin(): bool { return $this->public; } };
  49. }
  50. public function header($name) { return $this->token; }
  51. }
  52. function authenticate(?string $token, bool $public = false): array {
  53. $request = new TokenRequest($token, $public);
  54. return (new \app\api\http\middleware\LoginMiddleware())->handle($request, static fn($r) => ['code' => 1, 'user_id' => $r->userId, 'userInfo' => $r->userInfo]);
  55. }
  56. $cache = new \app\common\cache\UserTokenCache();
  57. $info = \app\api\service\UserTokenService::setToken('100001');
  58. $token = $info['token'];
  59. check($info['expire_time'], $start + 86400, 'Login issues 24-hour token');
  60. check(row($token)['update_time'], $start, 'Login records the activity clock');
  61. check(\think\facade\Cache::$entries['token_user_' . $token]['ttl'], 86400, 'Cache TTL equals the effective expiry');
  62. $GLOBALS['idle_token_now'] = $start + 3600;
  63. $unchanged = row($token);
  64. $readOnly = $cache->getUserInfo($token);
  65. check($readOnly['expire_time'], $start + 86400, 'Read-only crypto/WS lookup preserves deadline');
  66. check(row($token), $unchanged, 'Cache lookup never renews activity');
  67. \think\facade\Cache::$entries = [];
  68. check($cache->getUserInfo($token)['expire_time'], $start + 86400, 'Clearing cache does not restart the inactivity window');
  69. check(\think\facade\Cache::$entries['token_user_' . $token]['ttl'], 82800, 'Reloaded cache only gets remaining lifetime');
  70. $authenticated = authenticate($token);
  71. check($authenticated['code'], 1, 'Authenticated request succeeds');
  72. check($authenticated['userInfo']['expire_time'], $start + 3600 + 86400, 'Every valid request slides deadline by a full day');
  73. check(row($token)['update_time'], $start + 3600, 'Activity saved in database');
  74. check(\think\facade\Cache::$entries['token_user_' . $token]['ttl'], 86400, 'Renewal refreshes cache TTL');
  75. $GLOBALS['idle_token_now'] = $start + 25 * 3600 - 1;
  76. check(authenticate($token, true)['code'], 1, 'Valid authenticated polling on a public endpoint also renews');
  77. check(row($token)['expire_time'], $GLOBALS['idle_token_now'] + 86400, 'Active users stay logged in beyond an absolute login day');
  78. $lastActivity = $GLOBALS['idle_token_now'];
  79. $GLOBALS['idle_token_now'] = $lastActivity + 86400;
  80. $expiredBefore = row($token);
  81. \think\facade\Cache::set('token_user_' . $token, $info + ['unexpected' => 'old'], 7 * 86400);
  82. check(authenticate($token)['code'], 401, 'Exactly 24 hours without activity rejects authentication');
  83. check(\app\api\service\UserTokenService::overtimeToken($token), false, 'Expired token cannot be renewed directly');
  84. check(row($token), $expiredBefore, 'Expired request cannot revive the session');
  85. check($cache->getUserInfo($token), false, 'Stale cache cannot bypass expiry');
  86. check(authenticate($token, true)['user_id'], 0, 'Public endpoint treats invalid token as anonymous');
  87. check(authenticate(null)['code'], 401, 'Protected endpoint still requires a token');
  88. check(authenticate(null, true)['code'], 1, 'Anonymous public access stays available');
  89. // Interleave real logout/new-login between identity lookup and the conditional renewal.
  90. $raceMode = null; $raceToken = null; $replacementToken = null;
  91. $db->event('before_find', static function ($query) use (&$raceMode, &$raceToken, &$replacementToken) {
  92. if ($query->getTable() !== 'bot_users' || $raceMode === null) return;
  93. $mode = $raceMode; $raceMode = null;
  94. ++$GLOBALS['idle_token_now'];
  95. if ($mode === 'logout') \app\api\service\UserTokenService::expireToken($raceToken);
  96. else $replacementToken = \app\api\service\UserTokenService::setToken('100001')['token'];
  97. });
  98. foreach (['logout', 'replace'] as $mode) {
  99. $GLOBALS['idle_token_now'] += 60;
  100. $raceToken = \app\api\service\UserTokenService::setToken('100001')['token'];
  101. $GLOBALS['idle_token_now'] += 60;
  102. $raceMode = $mode;
  103. check(authenticate($raceToken)['code'], 401, 'Concurrent ' . $mode . ' wins over a stale renewal');
  104. if ($mode === 'logout') check(row($raceToken)['expire_time'], $GLOBALS['idle_token_now'], 'Concurrent logout is not overwritten');
  105. else {
  106. check(row($raceToken), null, 'Old token is not restored after a concurrent new login');
  107. check(row($replacementToken)['expire_time'], $GLOBALS['idle_token_now'] + 86400, 'Replacement token retains its deadline');
  108. }
  109. }
  110. // Seven-day legacy expiration cannot hide more than 24h inactivity.
  111. $GLOBALS['idle_token_now'] = $start + 3 * 86400;
  112. \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]);
  113. $legacy = row('legacy');
  114. check(authenticate('legacy')['code'], 401, 'Legacy long expiry still obeys the last-activity cutoff');
  115. check(row('legacy'), $legacy, 'Legacy expired sessions are not silently reactivated');
  116. foreach ([null, 0] as $index => $updateTime) {
  117. $fallbackToken = 'fallback-' . $index;
  118. \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]);
  119. check($cache->getUserInfo($fallbackToken)['expire_time'], $GLOBALS['idle_token_now'] + 82800, 'Missing activity falls back to creation time');
  120. check(authenticate($fallbackToken)['userInfo']['expire_time'], $GLOBALS['idle_token_now'] + 86400, 'Valid legacy session can renew without a new login');
  121. }
  122. $fresh = \app\api\service\UserTokenService::setToken('100001');
  123. $newToken = $fresh['token'];
  124. \think\facade\Cache::set('token_user_' . $token, $info, 86400);
  125. check($cache->getUserInfo($token), false, 'Old token remains invalid after a new login even with stale cache');
  126. $GLOBALS['idle_token_now'] += 60;
  127. check(\app\api\service\UserTokenService::expireToken($newToken), true, 'Logout revokes current token');
  128. $revoked = row($newToken);
  129. check(authenticate($newToken)['code'], 401, 'Logout token cannot authenticate');
  130. check(row($newToken), $revoked, 'Logout token cannot be resurrected by renewal');
  131. check(\app\api\service\UserTokenService::overtimeToken('does-not-exist'), false, 'Missing session fails without a null dereference');
  132. $fresh = \app\api\service\UserTokenService::setToken('100001'); $newToken = $fresh['token'];
  133. $future = $GLOBALS['idle_token_now'] + 10;
  134. \think\facade\Db::table('bot_user_session')->where('token', $newToken)->update(['update_time' => $future, 'expire_time' => $future + 86400]);
  135. $laterRequest = row($newToken);
  136. check(authenticate($newToken)['code'], 1, 'Older concurrent request still sees the valid renewed session');
  137. check(row($newToken), $laterRequest, 'An older renewal never moves deadline backwards');
  138. \think\facade\Db::table('bot_users')->where('user_id', '100001')->update(['delete_time' => time()]);
  139. $beforeMissingUser = row($newToken);
  140. check(authenticate($newToken)['code'], 401, 'Deleted user cannot authenticate through a cached profile');
  141. check(row($newToken), $beforeMissingUser, 'Failed identity check does not count as activity');
  142. echo 'PASS ' . $checks . " member idle-token assertions (isolated SQLite and memory cache; no live sessions)\n";
  143. }