\BcrClock::$now) ? $entry['value'] : null; } public function set($key, $value, $ttl = null): bool { self::$entries[$key] = ['value' => $value, 'tag' => static::class, 'until' => $ttl instanceof \DateTimeInterface ? $ttl->getTimestamp() : ($ttl ? \BcrClock::$now + $ttl : null)]; return true; } public function delete($key): bool { if (self::$keepDeleted) { return false; } unset(self::$entries[$key]); return true; } public function deleteTag(): bool { foreach (self::$entries as $key => $entry) { if ($entry['tag'] === static::class) { unset(self::$entries[$key]); } } return true; } } } namespace app\adminapi\http\middleware { function time(): int { return \BcrClock::$now; } } namespace app\adminapi\logic { function time(): int { return \BcrClock::$now; } } namespace app\adminapi\logic\auth { function time(): int { return \BcrClock::$now; } } namespace app\common\service { // These unrelated collaborators must never invoke a provider or read runtime configuration. final class FileService { public static function getFileUrl(string $uri = '', string $type = ''): string { return $uri; } public static function setFileUrl($uri) { return $uri; } } final class TencentImService { public function importAccount($id, $name): bool { return true; } } } namespace { $server = dirname(__DIR__); $dependencies = isset($argv[1]) ? realpath($argv[1]) : $server; if (!$dependencies || !is_file($dependencies . '/vendor/autoload.php')) { throw new RuntimeException('A local server vendor directory is required'); } $loader = require $dependencies . '/vendor/autoload.php'; $loader->addPsr4('app\\', $server . '/app', true); require $dependencies . '/vendor/topthink/framework/src/helper.php'; use app\adminapi\service\AdminTokenService as Tokens; use app\common\cache\AdminTokenCache as TokenCache; use app\common\cache\AdminAuthCache; use app\common\cache\BaseCache; use app\common\enum\AdminTerminalEnum as Terminal; use app\adminapi\http\middleware\LoginMiddleware; use app\adminapi\http\middleware\AuthMiddleware; use app\adminapi\logic\LoginLogic; use app\adminapi\logic\auth\AdminLogic; use app\adminapi\logic\tcm\MobileRecordingLogic; use think\facade\Db; function create_token(string $extra = ''): string { return bin2hex(random_bytes(16)); } function create_password(string $plaintext, string $salt): string { return md5($salt . md5($plaintext . $salt)); } function expectBcr(bool $ok, string $label): void { if (!$ok) { throw new RuntimeException($label); } $GLOBALS['bcrChecks']++; } function deniedBcr(callable $operation, string $label): void { try { $operation(); } catch (DomainException $error) { expectBcr(true, $label); return; } throw new RuntimeException('Expected denial: ' . $label); } $GLOBALS['bcrChecks'] = 0; if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) { throw new RuntimeException('In-memory SQLite required; no external database fallback'); } // A nonexistent root also prevents the constructor from including app/provider.php. $app = new think\App(sys_get_temp_dir() . '/bcr-session-no-app-' . bin2hex(random_bytes(8))); $app->instance('log', new Psr\Log\NullLogger()); $manager = new think\DbManager(); $manager->setConfig(['default' => 'sqlite', 'auto_timestamp' => false, 'datetime_format' => false, 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'zyt_']]]); $app->instance('think\DbManager', $manager); $config = new think\Config(); $config->set(['admin_token' => ['expire_duration' => 28800, 'be_expire_duration' => 3600, 'bcr_expire_duration' => 15552000, 'bcr_be_expire_duration' => 12960000], 'unique_identification' => 'synthetic-bcr-salt', 'default_image' => ['admin_avatar' => ''], 'data_scope' => ['enabled' => false]], 'project'); $config->set(['default' => 'test', 'channels' => ['test' => ['type' => 'File', 'close' => true]]], 'log'); $app->instance('config', $config); foreach (['WORK_WECHAT_FORCE_BIND_LOGIN', 'work_wechat.force_bind_login', 'work_wechat.work_wechat_force_bind_login', 'work_wechat.corp_id', 'work_wechat.secret', 'work_wechat.agent_id', 'wecom_rpa.server_ips'] as $key) { $app->env->set($key, ''); } foreach ([ 'CREATE TABLE zyt_admin (id INTEGER PRIMARY KEY, account TEXT, name TEXT, root INT, disable INT, delete_time INT, password TEXT, is_paw INT, multipoint_login INT, work_wechat_userid TEXT, avatar TEXT, update_time INT, gender INT, age INT, phone TEXT, title TEXT, department TEXT, specialty TEXT, education TEXT, experience TEXT, honors TEXT, license_no TEXT, qualification_images TEXT, enable_image_consult INT, enable_video_consult INT, enable_charge INT, login_time INT, login_ip TEXT)', 'CREATE TABLE zyt_admin_session (id INTEGER PRIMARY KEY AUTOINCREMENT, admin_id INT, terminal INT, token TEXT, expire_time INT, create_time INT, update_time INT, UNIQUE(admin_id, terminal))', 'CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)', 'CREATE TABLE zyt_admin_dept (admin_id INT, dept_id INT)', 'CREATE TABLE zyt_admin_jobs (admin_id INT, jobs_id INT)', 'CREATE TABLE zyt_system_role (id INTEGER PRIMARY KEY, name TEXT, data_scope INT, delete_time INT)', 'CREATE TABLE zyt_system_menu (id INTEGER PRIMARY KEY, perms TEXT, is_disable INT, delete_time INT)', 'CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)', 'CREATE TABLE zyt_tcm_diagnosis (id INTEGER PRIMARY KEY, patient_id INT, patient_name TEXT, phone TEXT, assistant_id INT, status INT, delete_time INT)', 'CREATE TABLE zyt_doctor_appointment (id INTEGER PRIMARY KEY, patient_id INT, doctor_id INT, status INT)', ] as $ddl) { Db::execute($ddl); } foreach ([11, 12, 13] as $id) { Db::name('admin')->insert(['id' => $id, 'account' => 'synthetic_' . $id, 'name' => 'Synthetic actor', 'root' => 0, 'disable' => 0, 'delete_time' => null, 'password' => create_password('old-pass', 'synthetic-bcr-salt'), 'is_paw' => 1, 'multipoint_login' => 1, 'work_wechat_userid' => '', 'avatar' => '']); Db::name('admin_role')->insert(['admin_id' => $id, 'role_id' => 2]); } Db::name('system_role')->insert(['id' => 2, 'name' => 'Synthetic assistant', 'data_scope' => 4]); Db::name('system_menu')->insertAll([ ['id' => 1, 'perms' => 'firstvisit.myPatient/lists', 'is_disable' => 0], ['id' => 2, 'perms' => 'tcm.diagnosis/lists', 'is_disable' => 0], ]); Db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => 1]); Db::name('tcm_diagnosis')->insertAll([ ['id' => 101, 'patient_id' => 501, 'patient_name' => 'Synthetic A', 'phone' => '', 'assistant_id' => 11, 'status' => 1, 'delete_time' => null], ['id' => 102, 'patient_id' => 502, 'patient_name' => 'Synthetic B', 'phone' => '', 'assistant_id' => 12, 'status' => 1, 'delete_time' => null], ]); $setRequest = static function (string $token = '', string $ip = '198.51.100.10', string $controller = 'tcm.mobileRecording', string $action = 'deviceLists') use ($app) { $request = (new think\Request())->withServer(['REMOTE_ADDR' => $ip])->withHeader(['token' => $token]); $request->setController($controller); $request->setAction($action); $request->controllerObject = new class { public function isNotNeedLogin(): bool { return false; } }; $app->instance('request', $request); return $request; }; $setRequest(); $cache = new TokenCache(); $session = static fn(string $token) => Db::name('admin_session')->where('token', $token)->find(); $login = static function (string $token, string $ip = '198.51.100.10') use ($setRequest) { $request = $setRequest($token, $ip); $result = (new LoginMiddleware())->handle($request, static fn($request) => ['code' => 1, 'admin' => $request->adminInfo]); return is_array($result) ? $result : $result->getData(); }; $authorize = static function (array $info, string $ip, string $controller = 'tcm.mobileRecording', string $action = 'deviceLists') use ($setRequest) { $request = $setRequest($info['token'] ?? '', $ip, $controller, $action); $request->adminInfo = $info; $result = (new AuthMiddleware())->handle($request, static fn() => ['code' => 1]); return is_array($result) ? $result : $result->getData(); }; $invalidateCache = static function () use ($cache) { $cache->deleteTag(); }; $fresh = static function (int $id = 11, int $terminal = Terminal::BCR, int $multi = 0) use ($setRequest): array { $setRequest(); return Tokens::setToken($id, $terminal, $multi); }; $base = BcrClock::$now; expectBcr(Terminal::BCR === 20 && Terminal::MOBILE === 2 && Terminal::wecomRpaTerminals() === [7, 9], 'dedicated terminal allocation'); $ssoTerminals = (new ReflectionClass(app\mcp\service\SsoService::class))->getReflectionConstant('SESSION_TERMINALS')->getValue(); expectBcr(!in_array(Terminal::BCR, $ssoTerminals, true), 'long BCR token does not acquire normal admin SSO authority'); $validator = new app\adminapi\validate\LoginValidate(); expectBcr($validator->only(['terminal'])->check(['terminal' => 20]), 'password login accepts BCR terminal'); $validator = new app\adminapi\validate\LoginValidate(); $validator->setLang($app->lang); expectBcr(!$validator->only(['terminal'])->check(['terminal' => 99]), 'unknown terminal rejected'); $legacy = []; foreach ([1, 2, 7, 8, 9] as $terminal) { $info = $fresh(11, $terminal); $legacy[$terminal] = $info; expectBcr($info['expire_time'] === $base + 28800 && Tokens::renewalWindow($terminal) === 3600, 'legacy duration ' . $terminal); } $bcr = $fresh(); expectBcr($bcr['expire_time'] === $base + 180 * 86400, 'BCR is valid for 180 days'); expectBcr($session($legacy[1]['token'])['expire_time'] === $base + 28800, 'BCR login leaves PC session unchanged'); BcrClock::$now = $base + 29 * 86400; expectBcr($login($bcr['token'])['code'] === 1 && $session($bcr['token'])['expire_time'] === $base + 180 * 86400, 'day 29 does not renew'); BcrClock::$now = $base + 30 * 86400; expectBcr($login($bcr['token'])['code'] === 1 && $session($bcr['token'])['expire_time'] === $base + 180 * 86400, 'exact 150 day remainder does not renew'); BcrClock::$now++; $renewed = $login($bcr['token']); expectBcr($renewed['code'] === 1 && $session($bcr['token'])['expire_time'] === BcrClock::$now + 180 * 86400, 'after 30 days renews for 180 days'); expectBcr($renewed['admin']['expire_time'] === BcrClock::$now + 180 * 86400, 'middleware uses renewed adminInfo'); $invalidateCache(); expectBcr($login($bcr['token'])['code'] === 1, 'cache loss reconstructs a valid long session'); $expired = $fresh(); Db::name('admin_session')->where('token', $expired['token'])->update(['expire_time' => BcrClock::$now]); BaseCache::$keepDeleted = true; expectBcr($login($expired['token'])['code'] === -1, 'warm cache cannot hide exact expiry'); expectBcr(Tokens::overtimeToken($expired['token']) === false && $session($expired['token'])['expire_time'] === BcrClock::$now, 'expired token cannot renew'); BaseCache::$keepDeleted = false; $invalidateCache(); expectBcr(!$cache->getAdminInfo($expired['token']), 'cold cache also rejects expiry'); $exactBoundaryLogin = $fresh(11, Terminal::BCR, 1); expectBcr($exactBoundaryLogin['token'] !== $expired['token'] && !$cache->getAdminInfo($expired['token']), 'multipoint login at exact expiry rotates token instead of reviving it'); $legacyMobile = $fresh(11, 2); Db::name('admin_session')->where('token', $legacyMobile['token'])->update(['expire_time' => BcrClock::$now + 100]); $invalidateCache(); $shortRenewal = $login($legacyMobile['token']); expectBcr($shortRenewal['admin']['terminal'] === 2 && $session($legacyMobile['token'])['expire_time'] === BcrClock::$now + 28800, 'terminal 2 never upgrades to BCR lifetime'); foreach (['disable', 'delete_time'] as $state) { $info = $fresh(); Db::name('admin')->where('id', 11)->update([$state => $state === 'disable' ? 1 : BcrClock::$now]); expectBcr(!$cache->getAdminInfo($info['token']), 'warm cache rejects ' . $state); $invalidateCache(); expectBcr(!$cache->getAdminInfo($info['token']), 'cold cache rejects ' . $state); expectBcr(Tokens::overtimeToken($info['token']) === false, 'renewal rejects ' . $state); Db::name('admin')->where('id', 11)->update([$state => $state === 'disable' ? 0 : null]); } $info = $fresh(13); Db::name('admin')->where('id', 13)->delete(); expectBcr(!$cache->getAdminInfo($info['token']), 'deleted admin row is rejected without dereferencing null'); $info = $fresh(); Db::name('admin_session')->where('token', $info['token'])->delete(); expectBcr(!$cache->getAdminInfo($info['token']) && Tokens::overtimeToken($info['token']) === false, 'removed session cannot be rebuilt'); // Real DB hook inserts a revocation/replacement between session read and renewal CAS. $interleave = null; Db::event('before_find', static function ($query) use (&$interleave): void { if ($interleave !== null && $query->getTable() === 'zyt_admin') { $operation = $interleave; $interleave = null; $operation(); } }); foreach (['logout', 'password', 'replacement', 'other_renewal'] as $race) { $info = $fresh(); $old = $info['token']; $replacement = bin2hex(random_bytes(16)); $interleave = static function () use ($race, $old, $replacement): void { if ($race === 'password') { Tokens::revokeBcrSessions(11); return; } $values = $race === 'replacement' ? ['token' => $replacement] : ['expire_time' => $race === 'logout' ? BcrClock::$now - 1 : BcrClock::$now + 181 * 86400]; Db::name('admin_session')->where('token', $old)->update($values); }; $result = Tokens::overtimeToken($old); expectBcr($interleave === null, 'interleaving executed: ' . $race); if ($race === 'other_renewal') { expectBcr($result['expire_time'] === BcrClock::$now + 181 * 86400, 'CAS loser preserves another successful renewal'); } else { expectBcr(!$result && !$cache->getAdminInfo($old), 'CAS cannot revive ' . $race); if ($race === 'replacement') { expectBcr($session($replacement) !== null, 'CAS cannot overwrite replacement token'); } } } $first = $fresh(); $same = $fresh(11, Terminal::BCR, 1); expectBcr($first['token'] === $same['token'], 'multipoint BCR reuses existing terminal token'); $replaced = $fresh(11, Terminal::BCR, 0); expectBcr($first['token'] !== $replaced['token'] && !$cache->getAdminInfo($first['token']), 'single-login BCR replaces old token'); $pc = $fresh(11, 1, 1); BaseCache::$keepDeleted = true; (new LoginLogic())->logout($replaced); expectBcr(!$cache->getAdminInfo($replaced['token']) && !Tokens::overtimeToken($replaced['token']), 'BCR logout revokes shared token despite multipoint and stale cache'); expectBcr($cache->getAdminInfo($pc['token']) !== false && Tokens::expireToken($pc['token']) === false, 'legacy multipoint logout is unchanged'); BaseCache::$keepDeleted = false; $sameSecondLogin = $fresh(11, Terminal::BCR, 1); expectBcr($sameSecondLogin['token'] !== $replaced['token'] && !$cache->getAdminInfo($replaced['token']), 'same-second login after BCR logout cannot revive revoked shared token'); foreach (['admin_edit', 'self_edit', 'first_password'] as $mode) { $info = $fresh(); $pc = $fresh(11, 1, 1); BaseCache::$keepDeleted = true; if ($mode === 'admin_edit') { $ok = AdminLogic::edit(['id' => 11, 'name' => 'Synthetic actor', 'account' => 'synthetic_11', 'disable' => 0, 'multipoint_login' => 1, 'role_id' => [2], 'password' => 'new-admin-pass']); expectBcr($ok, 'real AdminLogic::edit succeeds: ' . AdminLogic::getError()); } elseif ($mode === 'self_edit') { expectBcr((bool) AdminLogic::editSelf(['admin_id' => 11, 'name' => 'Synthetic actor', 'avatar' => '', 'password' => 'new-self-pass']), 'real self edit succeeds'); } else { expectBcr((new LoginLogic())->changeFirstPassword($pc['token'], 'new-first-pass'), 'real initial password change succeeds'); } expectBcr(!$cache->getAdminInfo($info['token']) && !Tokens::overtimeToken($info['token']), $mode . ' revokes long session despite stale cache'); expectBcr($session($pc['token'])['expire_time'] > BcrClock::$now, $mode . ' preserves existing legacy multipoint semantics'); BaseCache::$keepDeleted = false; } $info = $fresh(12); BaseCache::$keepDeleted = true; expectBcr(AdminLogic::edit(['id' => 12, 'name' => 'Synthetic actor', 'account' => 'synthetic_12', 'disable' => 1, 'multipoint_login' => 1, 'role_id' => [2]]), 'real admin disable succeeds'); expectBcr(!$cache->getAdminInfo($info['token']), 'admin disable revokes warm BCR cache'); Db::name('admin')->where('id', 12)->update(['disable' => 0]); expectBcr(!$cache->getAdminInfo($info['token']) && !Tokens::overtimeToken($info['token']), 'reenabling account cannot resurrect disabled session'); BaseCache::$keepDeleted = false; $info = $fresh(12); BaseCache::$keepDeleted = true; expectBcr(AdminLogic::delete(['id' => 12]), 'real admin delete succeeds'); expectBcr(!$cache->getAdminInfo($info['token']), 'admin delete revokes warm BCR cache'); Db::name('admin')->where('id', 12)->update(['delete_time' => null]); expectBcr(!$cache->getAdminInfo($info['token']) && !Tokens::overtimeToken($info['token']), 'restoring account cannot resurrect deleted session'); BaseCache::$keepDeleted = false; $info = $fresh(); expectBcr($authorize($info, '203.0.113.20')['code'] === 1, 'BCR Wi-Fi to mobile IP change is allowed'); expectBcr($authorize($info, '203.0.113.20', 'tcm.diagnosis', 'lists')['code'] === 0, 'IP exception does not bypass menu permissions'); foreach ([1, 2, 7, 8, 9] as $terminal) { $ordinary = $fresh(11, $terminal); expectBcr($authorize($ordinary, '203.0.113.20')['code'] === -1, 'legacy IP binding remains ' . $terminal); } $app->env->set('wecom_rpa.server_ips', '203.0.113.20'); foreach ([7, 9] as $terminal) { expectBcr($authorize($fresh(11, $terminal), '203.0.113.20')['code'] === 1, 'RPA trusted-server exception remains ' . $terminal); } expectBcr($authorize($fresh(11, 2), '203.0.113.20')['code'] === -1, 'generic mobile cannot use RPA exception'); $renewingPc = $fresh(11, 1); Db::name('admin_session')->where('token', $renewingPc['token'])->update(['expire_time' => BcrClock::$now + 100]); $cache->deleteAdminInfo($renewingPc['token']); $setRequest($renewingPc['token']); $cache->setAdminInfo($renewingPc['token']); $pcAfterRenewal = $login($renewingPc['token'], '203.0.113.20'); expectBcr($authorize($pcAfterRenewal['admin'], '203.0.113.20')['code'] === -1, 'legacy renewal request does not bypass original fixed IP check'); $app->env->set('work_wechat.force_bind_login', true); $app->env->set('work_wechat.corp_id', 'synthetic-corp'); $app->env->set('work_wechat.secret', 'synthetic-secret'); $app->env->set('work_wechat.agent_id', 'synthetic-agent'); expectBcr($login($info['token'], '203.0.113.20')['code'] === LoginLogic::CODE_NEED_BIND_WORK_WECHAT, 'BCR still requires configured WeCom binding'); Db::name('admin')->where('id', 11)->update(['work_wechat_userid' => 'synthetic-member']); $bound = $fresh(); expectBcr($login($bound['token'], '203.0.113.20')['code'] === 1, 'bound BCR account passes WeCom gate'); Db::name('admin')->where('id', 11)->update(['work_wechat_userid' => '']); expectBcr($login($bound['token'], '203.0.113.20')['code'] === LoginLogic::CODE_NEED_BIND_WORK_WECHAT, 'cached bound status cannot hide WeCom unlink'); $app->env->set('work_wechat.force_bind_login', false); Db::name('admin')->where('id', 11)->update(['root' => 1]); $rootInfo = $fresh(); Db::name('admin')->where('id', 11)->update(['root' => 0]); $liveInfo = $cache->getAdminInfo($rootInfo['token']); expectBcr($liveInfo['root'] === 0 && $authorize($liveInfo, '203.0.113.20', 'tcm.diagnosis', 'lists')['code'] === 0, 'cached root status cannot hide revoked root'); Db::name('admin_role')->where('admin_id', 11)->delete(); expectBcr($cache->getAdminInfo($rootInfo['token'])['role_id'] === [], 'role snapshot is live for downstream policy middleware'); Db::name('admin_role')->insert(['admin_id' => 11, 'role_id' => 2]); $actor = MobileRecordingLogic::actor(11); expectBcr(MobileRecordingLogic::diagnosis(101, 11, $actor)['id'] === 101, 'own patient remains accessible'); deniedBcr(static fn() => MobileRecordingLogic::diagnosis(102, 11, $actor), 'BCR has no access to another assistant patient'); Db::name('system_role_menu')->where('role_id', 2)->delete(); (new AdminAuthCache(11))->clearAuthCache(); expectBcr($authorize($info, '203.0.113.20')['code'] === 0, 'BCR IP exception cannot bypass removed role grant'); deniedBcr(static fn() => MobileRecordingLogic::actor(11), 'patient service rechecks revoked grant'); expectBcr(!$app->initialized(), 'test never initialized the application or loaded .env'); echo 'AdminBcrSessionTest: ' . $GLOBALS['bcrChecks'] . " checks passed (real classes, synthetic in-memory SQLite/cache, no network).\n"; }