From 2ea462aa314d338dccb12118a8fef5667ccd5103 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 12 Nov 2026 14:27:49 +0800 Subject: [PATCH] gengx --- .../http/middleware/AuthMiddleware.php | 2 + .../http/middleware/LoginMiddleware.php | 215 +++--- server/app/adminapi/logic/LoginLogic.php | 720 +++++++++--------- server/app/adminapi/logic/auth/AdminLogic.php | 12 +- .../adminapi/service/AdminTokenService.php | 309 ++++---- .../app/adminapi/validate/LoginValidate.php | 3 +- server/app/common/cache/AdminTokenCache.php | 262 ++++--- server/app/common/enum/AdminTerminalEnum.php | 2 + server/config/project.php | 3 + server/tests/AdminBcrSessionTest.php | 340 +++++++++ 10 files changed, 1150 insertions(+), 718 deletions(-) create mode 100644 server/tests/AdminBcrSessionTest.php diff --git a/server/app/adminapi/http/middleware/AuthMiddleware.php b/server/app/adminapi/http/middleware/AuthMiddleware.php index 8306238b6..8ec54daa0 100755 --- a/server/app/adminapi/http/middleware/AuthMiddleware.php +++ b/server/app/adminapi/http/middleware/AuthMiddleware.php @@ -50,6 +50,8 @@ class AuthMiddleware // 登录 IP 绑定;客服后台服务器代查患者/订单的例外见 isTrustedRpaServerRequest if ($request->adminInfo['login_ip'] != request()->ip() + // BCR 原生应用会在 Wi-Fi/移动网络间切换;仅跳过固定 IP,继续下面全部权限检查。 + && (int) ($request->adminInfo['terminal'] ?? 0) !== AdminTerminalEnum::BCR && !self::isTrustedRpaServerRequest( (array) $request->adminInfo, request()->ip(), diff --git a/server/app/adminapi/http/middleware/LoginMiddleware.php b/server/app/adminapi/http/middleware/LoginMiddleware.php index adbbe909e..6ad0b72cc 100755 --- a/server/app/adminapi/http/middleware/LoginMiddleware.php +++ b/server/app/adminapi/http/middleware/LoginMiddleware.php @@ -1,106 +1,109 @@ -header('token'); - //判断接口是否免登录 - $isNotNeedLogin = $request->controllerObject->isNotNeedLogin(); - - //不直接判断$isNotNeedLogin结果,使不需要登录的接口通过,为了兼容某些接口可以登录或不登录访问 - if (empty($token) && !$isNotNeedLogin) { - //没有token并且该地址需要登录才能访问 - return JsonService::fail('请求参数缺token', [], 0, 0); - } - - $adminInfo = (new AdminTokenCache())->getAdminInfo($token); - if (empty($adminInfo) && !$isNotNeedLogin) { - //token过期无效并且该地址需要登录才能访问 - return JsonService::fail('登录超时,请重新登录', [], -1); - } - - //token临近过期,自动续期 - if ($adminInfo) { - //获取临近过期自动续期时长 - $beExpireDuration = Config::get('project.admin_token.be_expire_duration'); - //token续期 - if (time() > ($adminInfo['expire_time'] - $beExpireDuration)) { - $result = AdminTokenService::overtimeToken($token); - //续期失败(数据表被删除导致) - if (empty($result)) { - return JsonService::fail('登录过期', [], -1); - } - } - } - - //给request赋值,用于控制器 - $request->adminInfo = $adminInfo; - $request->adminId = $adminInfo['admin_id'] ?? 0; - - // 非 root 须绑定企微:免登录接口不拦截(如 getConfig),避免 init 死循环 - if (!empty($adminInfo) && LoginLogic::adminMustBindWorkWechat($adminInfo) && !$isNotNeedLogin) { - if (!self::isWorkWechatBindExemptAction($request)) { - return JsonService::fail( - '请先绑定企业微信后再使用系统', - [], - LoginLogic::CODE_NEED_BIND_WORK_WECHAT, - 0 - ); - } - } - - return $next($request); - } - - private static function isWorkWechatBindExemptAction($request): bool - { - if (LoginLogic::isWorkWechatBindExemptActionName((string) $request->action())) { - return true; - } - // 与 AuthMiddleware 一致:未绑企微时仍允许打开面诊进度所需只读列表 - if ((int) $request->param('progress_board', 0) === 1 && strtolower((string) $request->action()) === 'lists') { - $c = strtolower((string) $request->controller()); - - return in_array($c, ['auth.admin', 'doctor.appointment'], true); - } - - return false; - } - -} \ No newline at end of file +header('token'); + //判断接口是否免登录 + $isNotNeedLogin = $request->controllerObject->isNotNeedLogin(); + + //不直接判断$isNotNeedLogin结果,使不需要登录的接口通过,为了兼容某些接口可以登录或不登录访问 + if (empty($token) && !$isNotNeedLogin) { + //没有token并且该地址需要登录才能访问 + return JsonService::fail('请求参数缺token', [], 0, 0); + } + + $adminInfo = (new AdminTokenCache())->getAdminInfo($token); + if (empty($adminInfo) && !$isNotNeedLogin) { + //token过期无效并且该地址需要登录才能访问 + return JsonService::fail('登录超时,请重新登录', [], -1); + } + + //token临近过期,自动续期 + if ($adminInfo) { + //获取临近过期自动续期时长 + $beExpireDuration = AdminTokenService::renewalWindow($adminInfo['terminal'] ?? 0); + //token续期 + if (time() > ($adminInfo['expire_time'] - $beExpireDuration)) { + $result = AdminTokenService::overtimeToken($token); + //续期失败(数据表被删除导致) + if (empty($result)) { + return JsonService::fail('登录过期', [], -1); + } + if ((int) ($adminInfo['terminal'] ?? 0) === AdminTerminalEnum::BCR) { + $adminInfo = $result; + } + } + } + + //给request赋值,用于控制器 + $request->adminInfo = $adminInfo; + $request->adminId = $adminInfo['admin_id'] ?? 0; + + // 非 root 须绑定企微:免登录接口不拦截(如 getConfig),避免 init 死循环 + if (!empty($adminInfo) && LoginLogic::adminMustBindWorkWechat($adminInfo) && !$isNotNeedLogin) { + if (!self::isWorkWechatBindExemptAction($request)) { + return JsonService::fail( + '请先绑定企业微信后再使用系统', + [], + LoginLogic::CODE_NEED_BIND_WORK_WECHAT, + 0 + ); + } + } + + return $next($request); + } + + private static function isWorkWechatBindExemptAction($request): bool + { + if (LoginLogic::isWorkWechatBindExemptActionName((string) $request->action())) { + return true; + } + // 与 AuthMiddleware 一致:未绑企微时仍允许打开面诊进度所需只读列表 + if ((int) $request->param('progress_board', 0) === 1 && strtolower((string) $request->action()) === 'lists') { + $c = strtolower((string) $request->controller()); + + return in_array($c, ['auth.admin', 'doctor.appointment'], true); + } + + return false; + } + +} diff --git a/server/app/adminapi/logic/LoginLogic.php b/server/app/adminapi/logic/LoginLogic.php index c48798e0e..4a943bb0f 100755 --- a/server/app/adminapi/logic/LoginLogic.php +++ b/server/app/adminapi/logic/LoginLogic.php @@ -1,358 +1,362 @@ -action() 比较,不区分大小写)。 - * 路由/网关若把 action 变成全小写,严格 in_array('bindWorkWechat') 会失败,导致绑定接口被误判为 code=10,扫码页反复刷新。 - */ - public static function isWorkWechatBindExemptActionName(string $action): bool - { - $a = strtolower(trim($action)); - - return in_array($a, [ - 'bindworkwechat', - 'unbindworkwechat', - 'myself', - 'logout', - ], true); - } - - /** - * @notes 管理员账号登录 - * @param $params - * @return false|mixed - * @throws \think\db\exception\DataNotFoundException - * @throws \think\db\exception\DbException - * @throws \think\db\exception\ModelNotFoundException - * @author 令狐冲 - * @date 2021/6/30 17:00 - */ - public function login($params) - { - $time = time(); - $admin = Admin::where('account', '=', $params['account'])->find(); - - //用户表登录信息更新 - $admin->login_time = $time; - $admin->login_ip = request()->ip(); - $admin->save(); - - //设置token - $adminInfo = AdminTokenService::setToken($admin->id, $params['terminal'], $admin->multipoint_login); - - //返回登录信息 - $avatar = $admin->avatar ? $admin->avatar : Config::get('project.default_image.admin_avatar'); - $avatar = FileService::getFileUrl($avatar); - $row = [ - 'name' => $adminInfo['name'], - 'avatar' => $avatar, - 'role_name' => $adminInfo['role_name'], - 'token' => $adminInfo['token'], - 'is_paw' => $admin->is_paw ?? 1, // 0=需要修改密码,1=正常 - ]; - $row['need_bind_work_wechat'] = self::adminMustBindWorkWechat([ - 'root' => (int) ($admin->root ?? 0), - 'work_wechat_userid' => (string) ($admin->work_wechat_userid ?? ''), - ]); - - return $row; - } - - /** - * 从 auth/getuserinfo 响应解析企业成员 userid。 - * 非通讯录成员时接口可能只返回 openid / external_userid,无 userid。 - */ - public static function workWechatUserIdFromAuthResponse(array $response): string - { - return trim((string) ($response['userid'] ?? $response['UserId'] ?? '')); - } - - /** - * @notes 企业微信授权登录 - */ - public function workWechatLogin($params) - { - $code = $params['code']; - $terminal = $params['terminal'] ?? 1; - - $corpId = env('work_wechat.corp_id', ''); - $secret = env('work_wechat.secret', ''); - - if (empty($corpId) || empty($secret)) { - self::setError('企业微信未配置'); - return false; - } - - // 获取 access_token - $accessToken = self::getWorkWechatAccessToken($corpId, $secret); - if (!$accessToken) { - return false; - } - - // 用 code 换取用户身份 - $url = "https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={$accessToken}&code={$code}"; - $response = self::httpGet($url); - - if (!$response || $response['errcode'] != 0) { - $errMsg = $response['errmsg'] ?? '未知错误'; - Log::error('企业微信获取用户身份失败: ' . $errMsg); - self::setError('企业微信授权失败: ' . $errMsg); - return false; - } - - $wxUserId = self::workWechatUserIdFromAuthResponse($response); - if ($wxUserId === '') { - $hasOpenId = trim((string) ($response['openid'] ?? $response['OpenId'] ?? '')) !== ''; - self::setError( - $hasOpenId - ? '当前身份非企业通讯录成员(或未同步到通讯录),无法用此账号登录后台,请使用企业内成员账号或联系管理员将你加入通讯录' - : '未获取到企业微信成员 userid,请检查自建应用 Secret、可信域名是否与当前扫码应用一致' - ); - return false; - } - - // 通过 work_wechat_userid 匹配管理员(SoftDelete trait 自动排除已删除记录) - $admin = Admin::where('work_wechat_userid', '=', $wxUserId)->find(); - - if (!$admin) { - self::setError('该企业微信账号未绑定管理员,请联系管理员绑定(UserId: ' . $wxUserId . ')'); - return false; - } - - if ($admin->disable === 1) { - self::setError('账号已被禁用'); - return false; - } - - // 更新登录信息 - $time = time(); - $admin->login_time = $time; - $admin->login_ip = request()->ip(); - $admin->save(); - - // 设置 token - $adminInfo = AdminTokenService::setToken($admin->id, $terminal, $admin->multipoint_login); - - $avatar = $admin->avatar ?: Config::get('project.default_image.admin_avatar'); - $avatar = FileService::getFileUrl($avatar); - - return [ - 'name' => $adminInfo['name'], - 'avatar' => $avatar, - 'role_name' => $adminInfo['role_name'], - 'token' => $adminInfo['token'], - 'is_paw' => $admin->is_paw ?? 1, // 0=需要修改密码,1=正常 - // 企微扫码登录即已绑定 userid - 'need_bind_work_wechat' => false, - ]; - } - - /** - * @notes 获取企业微信 access_token(带缓存),供外部(如绑定流程)调用 - */ - public static function getWorkWechatAccessTokenStatic(string $corpId, string $secret) - { - return self::getWorkWechatAccessToken($corpId, $secret); - } - - /** - * @notes 获取企业微信 access_token(带缓存) - */ - private static function getWorkWechatAccessToken(string $corpId, string $secret) - { - $cacheKey = 'work_wechat_access_token_' . md5($corpId . $secret); - $cached = Cache::get($cacheKey); - if ($cached) { - return $cached; - } - - $url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={$corpId}&corpsecret={$secret}"; - $response = self::httpGet($url); - - if (!$response || $response['errcode'] != 0) { - $errMsg = $response['errmsg'] ?? '未知错误'; - Log::error('获取企业微信access_token失败: ' . $errMsg); - self::setError('获取企业微信凭证失败: ' . $errMsg); - return false; - } - - $accessToken = $response['access_token']; - Cache::set($cacheKey, $accessToken, $response['expires_in'] - 200); - - return $accessToken; - } - - /** - * @notes HTTP GET 请求 - */ - private static function httpGet(string $url) - { - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - $result = curl_exec($ch); - curl_close($ch); - - if ($result === false) { - return null; - } - - return json_decode($result, true); - } - - /** - * @notes 退出登录 - * @param $adminInfo - * @return bool - * @throws \think\db\exception\DataNotFoundException - * @throws \think\db\exception\DbException - * @throws \think\db\exception\ModelNotFoundException - * @author 令狐冲 - * @date 2021/7/5 14:34 - */ - public function logout($adminInfo) - { - //token不存在,不注销 - if (!isset($adminInfo['token'])) { - return false; - } - //设置token过期 - return AdminTokenService::expireToken($adminInfo['token']); - } - - /** - * @notes 首次登录修改密码 - * @param string $token - * @param string $password - * @return bool - */ - public function changeFirstPassword($token, $password) - { - try { - // 通过 token 获取管理员信息 - $adminSession = \app\common\model\auth\AdminSession::where('token', '=', $token) - ->where('expire_time', '>', time()) - ->find(); - - if (!$adminSession) { - self::setError('登录已过期,请重新登录'); - return false; - } - - $admin = Admin::find($adminSession->admin_id); - if (!$admin) { - self::setError('管理员不存在'); - return false; - } - - // 使用系统的密码加密方式 - $passwordSalt = Config::get('project.unique_identification'); - $encryptedPassword = create_password($password, $passwordSalt); - - // 更新密码和 is_paw 标记 - $admin->password = $encryptedPassword; - $admin->is_paw = 1; - $admin->save(); - - // 使当前 token 过期,要求重新登录 - AdminTokenService::expireToken($token); - - return true; - } catch (\Exception $e) { - self::setError($e->getMessage()); - return false; - } - } -} \ No newline at end of file +action() 比较,不区分大小写)。 + * 路由/网关若把 action 变成全小写,严格 in_array('bindWorkWechat') 会失败,导致绑定接口被误判为 code=10,扫码页反复刷新。 + */ + public static function isWorkWechatBindExemptActionName(string $action): bool + { + $a = strtolower(trim($action)); + + return in_array($a, [ + 'bindworkwechat', + 'unbindworkwechat', + 'myself', + 'logout', + ], true); + } + + /** + * @notes 管理员账号登录 + * @param $params + * @return false|mixed + * @throws \think\db\exception\DataNotFoundException + * @throws \think\db\exception\DbException + * @throws \think\db\exception\ModelNotFoundException + * @author 令狐冲 + * @date 2021/6/30 17:00 + */ + public function login($params) + { + $time = time(); + $admin = Admin::where('account', '=', $params['account'])->find(); + + //用户表登录信息更新 + $admin->login_time = $time; + $admin->login_ip = request()->ip(); + $admin->save(); + + //设置token + $adminInfo = AdminTokenService::setToken($admin->id, $params['terminal'], $admin->multipoint_login); + + //返回登录信息 + $avatar = $admin->avatar ? $admin->avatar : Config::get('project.default_image.admin_avatar'); + $avatar = FileService::getFileUrl($avatar); + $row = [ + 'name' => $adminInfo['name'], + 'avatar' => $avatar, + 'role_name' => $adminInfo['role_name'], + 'token' => $adminInfo['token'], + 'is_paw' => $admin->is_paw ?? 1, // 0=需要修改密码,1=正常 + ]; + $row['need_bind_work_wechat'] = self::adminMustBindWorkWechat([ + 'root' => (int) ($admin->root ?? 0), + 'work_wechat_userid' => (string) ($admin->work_wechat_userid ?? ''), + ]); + + return $row; + } + + /** + * 从 auth/getuserinfo 响应解析企业成员 userid。 + * 非通讯录成员时接口可能只返回 openid / external_userid,无 userid。 + */ + public static function workWechatUserIdFromAuthResponse(array $response): string + { + return trim((string) ($response['userid'] ?? $response['UserId'] ?? '')); + } + + /** + * @notes 企业微信授权登录 + */ + public function workWechatLogin($params) + { + $code = $params['code']; + $terminal = $params['terminal'] ?? 1; + + $corpId = env('work_wechat.corp_id', ''); + $secret = env('work_wechat.secret', ''); + + if (empty($corpId) || empty($secret)) { + self::setError('企业微信未配置'); + return false; + } + + // 获取 access_token + $accessToken = self::getWorkWechatAccessToken($corpId, $secret); + if (!$accessToken) { + return false; + } + + // 用 code 换取用户身份 + $url = "https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={$accessToken}&code={$code}"; + $response = self::httpGet($url); + + if (!$response || $response['errcode'] != 0) { + $errMsg = $response['errmsg'] ?? '未知错误'; + Log::error('企业微信获取用户身份失败: ' . $errMsg); + self::setError('企业微信授权失败: ' . $errMsg); + return false; + } + + $wxUserId = self::workWechatUserIdFromAuthResponse($response); + if ($wxUserId === '') { + $hasOpenId = trim((string) ($response['openid'] ?? $response['OpenId'] ?? '')) !== ''; + self::setError( + $hasOpenId + ? '当前身份非企业通讯录成员(或未同步到通讯录),无法用此账号登录后台,请使用企业内成员账号或联系管理员将你加入通讯录' + : '未获取到企业微信成员 userid,请检查自建应用 Secret、可信域名是否与当前扫码应用一致' + ); + return false; + } + + // 通过 work_wechat_userid 匹配管理员(SoftDelete trait 自动排除已删除记录) + $admin = Admin::where('work_wechat_userid', '=', $wxUserId)->find(); + + if (!$admin) { + self::setError('该企业微信账号未绑定管理员,请联系管理员绑定(UserId: ' . $wxUserId . ')'); + return false; + } + + if ($admin->disable === 1) { + self::setError('账号已被禁用'); + return false; + } + + // 更新登录信息 + $time = time(); + $admin->login_time = $time; + $admin->login_ip = request()->ip(); + $admin->save(); + + // 设置 token + $adminInfo = AdminTokenService::setToken($admin->id, $terminal, $admin->multipoint_login); + + $avatar = $admin->avatar ?: Config::get('project.default_image.admin_avatar'); + $avatar = FileService::getFileUrl($avatar); + + return [ + 'name' => $adminInfo['name'], + 'avatar' => $avatar, + 'role_name' => $adminInfo['role_name'], + 'token' => $adminInfo['token'], + 'is_paw' => $admin->is_paw ?? 1, // 0=需要修改密码,1=正常 + // 企微扫码登录即已绑定 userid + 'need_bind_work_wechat' => false, + ]; + } + + /** + * @notes 获取企业微信 access_token(带缓存),供外部(如绑定流程)调用 + */ + public static function getWorkWechatAccessTokenStatic(string $corpId, string $secret) + { + return self::getWorkWechatAccessToken($corpId, $secret); + } + + /** + * @notes 获取企业微信 access_token(带缓存) + */ + private static function getWorkWechatAccessToken(string $corpId, string $secret) + { + $cacheKey = 'work_wechat_access_token_' . md5($corpId . $secret); + $cached = Cache::get($cacheKey); + if ($cached) { + return $cached; + } + + $url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={$corpId}&corpsecret={$secret}"; + $response = self::httpGet($url); + + if (!$response || $response['errcode'] != 0) { + $errMsg = $response['errmsg'] ?? '未知错误'; + Log::error('获取企业微信access_token失败: ' . $errMsg); + self::setError('获取企业微信凭证失败: ' . $errMsg); + return false; + } + + $accessToken = $response['access_token']; + Cache::set($cacheKey, $accessToken, $response['expires_in'] - 200); + + return $accessToken; + } + + /** + * @notes HTTP GET 请求 + */ + private static function httpGet(string $url) + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + $result = curl_exec($ch); + curl_close($ch); + + if ($result === false) { + return null; + } + + return json_decode($result, true); + } + + /** + * @notes 退出登录 + * @param $adminInfo + * @return bool + * @throws \think\db\exception\DataNotFoundException + * @throws \think\db\exception\DbException + * @throws \think\db\exception\ModelNotFoundException + * @author 令狐冲 + * @date 2021/7/5 14:34 + */ + public function logout($adminInfo) + { + //token不存在,不注销 + if (!isset($adminInfo['token'])) { + return false; + } + //设置token过期 + return AdminTokenService::expireToken($adminInfo['token']); + } + + /** + * @notes 首次登录修改密码 + * @param string $token + * @param string $password + * @return bool + */ + public function changeFirstPassword($token, $password) + { + try { + // 通过 token 获取管理员信息 + $adminSession = \app\common\model\auth\AdminSession::where('token', '=', $token) + ->where('expire_time', '>', time()) + ->find(); + + if (!$adminSession) { + self::setError('登录已过期,请重新登录'); + return false; + } + + $admin = Admin::find($adminSession->admin_id); + if (!$admin) { + self::setError('管理员不存在'); + return false; + } + + // 使用系统的密码加密方式 + $passwordSalt = Config::get('project.unique_identification'); + $encryptedPassword = create_password($password, $passwordSalt); + + // 更新密码和 is_paw 标记 + $admin->password = $encryptedPassword; + $admin->is_paw = 1; + Db::transaction(static function () use ($admin) { + $admin->save(); + AdminTokenService::revokeBcrSessions((int) $admin->id); + }); + + // 使当前 token 过期,要求重新登录 + AdminTokenService::expireToken($token); + + return true; + } catch (\Exception $e) { + self::setError($e->getMessage()); + return false; + } + } +} diff --git a/server/app/adminapi/logic/auth/AdminLogic.php b/server/app/adminapi/logic/auth/AdminLogic.php index ee729ea41..e8b574625 100755 --- a/server/app/adminapi/logic/auth/AdminLogic.php +++ b/server/app/adminapi/logic/auth/AdminLogic.php @@ -16,6 +16,7 @@ namespace app\adminapi\logic\auth; use app\adminapi\logic\LoginLogic; use app\adminapi\service\DoctorWebConsultationPolicy; +use app\adminapi\service\AdminTokenService; use app\common\cache\AdminAuthCache; use app\common\enum\YesNoEnum; use app\common\logic\BaseLogic; @@ -176,6 +177,9 @@ class AdminLogic extends BaseLogic } Admin::update($data); + if (isset($data['password'])) { + AdminTokenService::revokeBcrSessions((int) $params['id']); + } // 删除旧的关联信息 AdminRole::delByUserId($params['id']); @@ -355,7 +359,13 @@ class AdminLogic extends BaseLogic $data['password'] = create_password($params['password'], $passwordSalt); } - return Admin::update($data); + return Db::transaction(static function () use ($data) { + $admin = Admin::update($data); + if (isset($data['password'])) { + AdminTokenService::revokeBcrSessions((int) $data['id']); + } + return $admin; + }); } diff --git a/server/app/adminapi/service/AdminTokenService.php b/server/app/adminapi/service/AdminTokenService.php index df7fd4e0c..c6f99ca26 100755 --- a/server/app/adminapi/service/AdminTokenService.php +++ b/server/app/adminapi/service/AdminTokenService.php @@ -1,132 +1,177 @@ -find(); - - //获取token延长过期的时间 - $expireTime = $time + Config::get('project.admin_token.expire_duration'); - $adminTokenCache = new AdminTokenCache(); - - //token处理 - if ($adminSession) { - if ($adminSession->expire_time < $time || $multipointLogin === 0) { - //清空缓存 - $adminTokenCache->deleteAdminInfo($adminSession->token); - //如果token过期或账号设置不支持多处登录,更新token - $adminSession->token = create_token($adminId); - } - $adminSession->expire_time = $expireTime; - $adminSession->update_time = $time; - - $adminSession->save(); - } else { - //找不到在该终端的token记录,创建token记录 - $adminSession = AdminSession::create([ - 'admin_id' => $adminId, - 'terminal' => $terminal, - 'token' => create_token($adminId), - 'expire_time' => $expireTime - ]); - } - - return $adminTokenCache->setAdminInfo($adminSession->token); - } - - /** - * @notes 延长token过期时间 - * @param $token - * @return array|false|mixed - * @throws \think\db\exception\DataNotFoundException - * @throws \think\db\exception\DbException - * @throws \think\db\exception\ModelNotFoundException - * @author 令狐冲 - * @date 2021/7/5 14:25 - */ - public static function overtimeToken($token) - { - $time = time(); - $adminSession = AdminSession::where('token', '=', $token)->findOrEmpty(); - if ($adminSession->isEmpty()) { - return false; - } - //延长token过期时间 - $adminSession->expire_time = $time + Config::get('project.admin_token.expire_duration'); - $adminSession->update_time = $time; - $adminSession->save(); - return (new AdminTokenCache())->setAdminInfo($adminSession->token); - } - - /** - * @notes 设置token为过期 - * @param $token - * @return bool - * @throws \think\db\exception\DataNotFoundException - * @throws \think\db\exception\DbException - * @throws \think\db\exception\ModelNotFoundException - * @author 令狐冲 - * @date 2021/7/5 14:31 - */ - public static function expireToken($token) - { - $adminSession = AdminSession::where('token', '=', $token) - ->with('admin') - ->findOrEmpty(); - - if ($adminSession->isEmpty()) { - return false; - } - - //当支持多处登录的时候,服务端不注销 - if ($adminSession->admin->multipoint_login === 1) { - return false; - } - - $time = time(); - $adminSession->expire_time = $time; - $adminSession->update_time = $time; - $adminSession->save(); - - return (new AdminTokenCache())->deleteAdminInfo($token); - } - -} \ No newline at end of file +where('terminal', AdminTerminalEnum::BCR) + ->update(['expire_time' => time() - 1, 'update_time' => time()]); + } + + /** + * @notes 设置或更新管理员token + * @param $adminId //管理员id + * @param $terminal //多终端名称 + * @param $multipointLogin //是否支持多处登录 + * @return false|mixed + * @throws \think\db\exception\DataNotFoundException + * @throws \think\db\exception\DbException + * @throws \think\db\exception\ModelNotFoundException + * @author 令狐冲 + * @date 2021/7/2 20:25 + */ + public static function setToken($adminId, $terminal, $multipointLogin = 1) + { + $time = time(); + $adminSession = AdminSession::where([['admin_id', '=', $adminId], ['terminal', '=', $terminal]])->find(); + + //获取token延长过期的时间 + $expireTime = $time + self::expireDuration($terminal); + $adminTokenCache = new AdminTokenCache(); + + //token处理 + if ($adminSession) { + if ($adminSession->expire_time < $time || $multipointLogin === 0 + || ((int) $terminal === AdminTerminalEnum::BCR && (int) $adminSession->expire_time === $time)) { + //清空缓存 + $adminTokenCache->deleteAdminInfo($adminSession->token); + //如果token过期或账号设置不支持多处登录,更新token + $adminSession->token = create_token($adminId); + } + $adminSession->expire_time = $expireTime; + $adminSession->update_time = $time; + + $adminSession->save(); + } else { + //找不到在该终端的token记录,创建token记录 + $adminSession = AdminSession::create([ + 'admin_id' => $adminId, + 'terminal' => $terminal, + 'token' => create_token($adminId), + 'expire_time' => $expireTime + ]); + } + + return $adminTokenCache->setAdminInfo($adminSession->token); + } + + /** + * @notes 延长token过期时间 + * @param $token + * @return array|false|mixed + * @throws \think\db\exception\DataNotFoundException + * @throws \think\db\exception\DbException + * @throws \think\db\exception\ModelNotFoundException + * @author 令狐冲 + * @date 2021/7/5 14:25 + */ + public static function overtimeToken($token) + { + $time = time(); + $adminSession = AdminSession::where('token', '=', $token)->findOrEmpty(); + if ($adminSession->isEmpty()) { + return false; + } + if ((int) $adminSession->terminal === AdminTerminalEnum::BCR) { + $previousExpiry = (int) $adminSession->expire_time; + if ($previousExpiry <= $time || !Admin::where('id', $adminSession->admin_id) + ->where('disable', 0)->field('id')->find()) { + return false; + } + // 比较读取时的有效期和 token;注销、改密或同端重登后不能把旧会话续活。 + // CAS 失败也可能是另一请求先续期,交给 DB 有效性校验区分。 + AdminSession::where('id', $adminSession->id)->where('token', $token) + ->where('expire_time', $previousExpiry)->where('expire_time', '>', $time) + ->update(['expire_time' => $time + self::expireDuration($adminSession->terminal), 'update_time' => $time]); + return (new AdminTokenCache())->setAdminInfo($token); + } + //延长token过期时间 + $adminSession->expire_time = $time + self::expireDuration($adminSession->terminal); + $adminSession->update_time = $time; + $adminSession->save(); + return (new AdminTokenCache())->setAdminInfo($adminSession->token); + } + + /** + * @notes 设置token为过期 + * @param $token + * @return bool + * @throws \think\db\exception\DataNotFoundException + * @throws \think\db\exception\DbException + * @throws \think\db\exception\ModelNotFoundException + * @author 令狐冲 + * @date 2021/7/5 14:31 + */ + public static function expireToken($token) + { + $adminSession = AdminSession::where('token', '=', $token) + ->with('admin') + ->findOrEmpty(); + + if ($adminSession->isEmpty()) { + return false; + } + + // 其他终端保留多处登录的原有退出语义。 + // BCR 必须注销;同账号多台 BCR 复用一个 token 时,这些设备会一起失效。 + if ((int) $adminSession->terminal !== AdminTerminalEnum::BCR + && $adminSession->admin->multipoint_login === 1) { + return false; + } + + $time = time(); + if ((int) $adminSession->terminal === AdminTerminalEnum::BCR) { + // 只注销调用者持有的 token,不能误伤并发重登后替换到同一行的新 token。 + AdminSession::where('id', $adminSession->id)->where('token', $token) + ->update(['expire_time' => $time - 1, 'update_time' => $time]); + } else { + $adminSession->expire_time = $time; + $adminSession->update_time = $time; + $adminSession->save(); + } + + return (new AdminTokenCache())->deleteAdminInfo($token); + } + +} diff --git a/server/app/adminapi/validate/LoginValidate.php b/server/app/adminapi/validate/LoginValidate.php index 734cc39a9..f9c7ff511 100755 --- a/server/app/adminapi/validate/LoginValidate.php +++ b/server/app/adminapi/validate/LoginValidate.php @@ -33,7 +33,8 @@ class LoginValidate extends BaseValidate { protected $rule = [ 'terminal' => 'require|in:' . AdminTerminalEnum::PC . ',' . AdminTerminalEnum::MOBILE - . ',' . AdminTerminalEnum::WECOM_RPA . ',' . AdminTerminalEnum::WECOM_RPA_MOBILE, + . ',' . AdminTerminalEnum::WECOM_RPA . ',' . AdminTerminalEnum::WECOM_RPA_MOBILE + . ',' . AdminTerminalEnum::BCR, 'account' => 'require', 'password' => 'require|password', ]; diff --git a/server/app/common/cache/AdminTokenCache.php b/server/app/common/cache/AdminTokenCache.php index 2b8aae7be..6bedc18a5 100755 --- a/server/app/common/cache/AdminTokenCache.php +++ b/server/app/common/cache/AdminTokenCache.php @@ -1,120 +1,142 @@ -get($this->prefix . $token); - if ($adminInfo) { - return $adminInfo; - } - - //从数据获取信息被设置缓存(可能后台清除缓存) - $adminInfo = $this->setAdminInfo($token); - if ($adminInfo) { - return $adminInfo; - } - - return false; - } - - /** - * @notes 通过有效token设置管理信息缓存 - * @param $token - * @return array|false|mixed - * @throws \think\db\exception\DataNotFoundException - * @throws \think\db\exception\DbException - * @throws \think\db\exception\ModelNotFoundException - * @author 令狐冲 - * @date 2021/7/5 12:12 - */ - public function setAdminInfo($token) - { - $adminSession = AdminSession::where([['token', '=', $token], ['expire_time', '>', time()]]) - ->find(); - if (empty($adminSession)) { - return []; - } - $admin = Admin::where('id', '=', $adminSession->admin_id) - ->append(['role_id']) - ->find(); - - $roleName = ''; - $roleLists = SystemRole::column('name', 'id'); - if ($admin['root'] == 1) { - $roleName = '系统管理员'; - } else { - foreach ($admin['role_id'] as $roleId) { - $roleName .= $roleLists[$roleId] ?? ''; - $roleName .= '/'; - } - $roleName = trim($roleName, '/'); - } - - $adminInfo = [ - 'admin_id' => $admin->id, - 'root' => $admin->root, - 'name' => $admin->name, - 'account' => $admin->account, - 'role_name' => $roleName, - 'role_id' => $admin->role_id, - 'token' => $token, - 'terminal' => $adminSession->terminal, - 'expire_time' => $adminSession->expire_time, - 'login_ip' => request()->ip(), - 'work_wechat_userid' => $admin->work_wechat_userid ?? '', - ]; - $this->set($this->prefix . $token, $adminInfo, new \DateTime(Date('Y-m-d H:i:s', $adminSession->expire_time))); - return $this->getAdminInfo($token); - } - - /** - * @notes 删除缓存 - * @param $token - * @return bool - * @author 令狐冲 - * @date 2021/7/3 16:57 - */ - public function deleteAdminInfo($token) - { - return $this->delete($this->prefix . $token); - } - - -} \ No newline at end of file +get($this->prefix . $token); + if ($adminInfo) { + if ((int) ($adminInfo['terminal'] ?? 0) === AdminTerminalEnum::BCR) { + // 长会话每次请求查 DB;即使事务撤销后旧缓存尚未删除,也必须拒绝。 + $session = AdminSession::where('token', $token)->where('expire_time', '>', time())->find(); + $admin = $session ? Admin::where('id', $session->admin_id)->where('disable', 0) + ->field('id,root,work_wechat_userid')->find() : null; + if (!$session || (int) $session->terminal !== AdminTerminalEnum::BCR + || (int) $session->admin_id !== (int) $adminInfo['admin_id'] + || !$admin) { + $this->deleteAdminInfo($token); + return false; + } + // 并发续期后以数据库的新到期时间为准。 + $adminInfo['expire_time'] = (int) $session->expire_time; + // 解绑企微/权限变化不能因长缓存而延迟生效。 + $adminInfo['root'] = (int) $admin->root; + $adminInfo['role_id'] = $admin->role_id; + $adminInfo['work_wechat_userid'] = (string) ($admin->work_wechat_userid ?? ''); + } + return $adminInfo; + } + + //从数据获取信息被设置缓存(可能后台清除缓存) + $adminInfo = $this->setAdminInfo($token); + if ($adminInfo) { + return $adminInfo; + } + + return false; + } + + /** + * @notes 通过有效token设置管理信息缓存 + * @param $token + * @return array|false|mixed + * @throws \think\db\exception\DataNotFoundException + * @throws \think\db\exception\DbException + * @throws \think\db\exception\ModelNotFoundException + * @author 令狐冲 + * @date 2021/7/5 12:12 + */ + public function setAdminInfo($token) + { + $adminSession = AdminSession::where([['token', '=', $token], ['expire_time', '>', time()]]) + ->find(); + if (empty($adminSession)) { + return []; + } + $admin = Admin::where('id', '=', $adminSession->admin_id) + ->append(['role_id']) + ->find(); + if (!$admin || ((int) $adminSession->terminal === AdminTerminalEnum::BCR && (int) $admin->disable !== 0)) { + return []; + } + + $roleName = ''; + $roleLists = SystemRole::column('name', 'id'); + if ($admin['root'] == 1) { + $roleName = '系统管理员'; + } else { + foreach ($admin['role_id'] as $roleId) { + $roleName .= $roleLists[$roleId] ?? ''; + $roleName .= '/'; + } + $roleName = trim($roleName, '/'); + } + + $adminInfo = [ + 'admin_id' => $admin->id, + 'root' => $admin->root, + 'name' => $admin->name, + 'account' => $admin->account, + 'role_name' => $roleName, + 'role_id' => $admin->role_id, + 'token' => $token, + 'terminal' => $adminSession->terminal, + 'expire_time' => $adminSession->expire_time, + 'login_ip' => request()->ip(), + 'work_wechat_userid' => $admin->work_wechat_userid ?? '', + ]; + $this->set($this->prefix . $token, $adminInfo, new \DateTime(Date('Y-m-d H:i:s', $adminSession->expire_time))); + return $adminInfo; + } + + /** + * @notes 删除缓存 + * @param $token + * @return bool + * @author 令狐冲 + * @date 2021/7/3 16:57 + */ + public function deleteAdminInfo($token) + { + return $this->delete($this->prefix . $token); + } + + +} diff --git a/server/app/common/enum/AdminTerminalEnum.php b/server/app/common/enum/AdminTerminalEnum.php index 70fd2e33a..17928110e 100755 --- a/server/app/common/enum/AdminTerminalEnum.php +++ b/server/app/common/enum/AdminTerminalEnum.php @@ -25,6 +25,8 @@ class AdminTerminalEnum { const PC = 1; const MOBILE = 2; + // BCR 原生录音应用:独立于通用手机后台及集团身份会话。 + const BCR = 20; // 企业微信客服桌面软件。与网页端分开保存会话,桌面登录不会顶掉浏览器登录。 const WECOM_RPA = 7; // 8 已被 AI 浏览器(app/mcp/service/ConsoleService::TERMINAL)占用,不要复用。 diff --git a/server/config/project.php b/server/config/project.php index 1b4d168f4..920bfbb9f 100755 --- a/server/config/project.php +++ b/server/config/project.php @@ -35,6 +35,9 @@ return [ 'admin_token' => [ 'expire_duration' => 3600 * 8,//管理后台token过期时长(单位秒) 'be_expire_duration' => 3600,//管理后台token临时过期前时长,自动续期 + // 仅 BCR 专用终端;约每 30 天活跃请求续期一次,不改变其他后台终端。 + 'bcr_expire_duration' => 86400 * 180, + 'bcr_be_expire_duration' => 86400 * 150, ], // 商城用户token(登录令牌)配置 diff --git a/server/tests/AdminBcrSessionTest.php b/server/tests/AdminBcrSessionTest.php new file mode 100644 index 000000000..d839d0fd9 --- /dev/null +++ b/server/tests/AdminBcrSessionTest.php @@ -0,0 +1,340 @@ + \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"; +}