This commit is contained in:
Your Name
2026-11-12 14:27:49 +08:00
parent b9ac9a6635
commit 2ea462aa31
10 changed files with 1150 additions and 718 deletions
@@ -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(),
@@ -1,106 +1,109 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\adminapi\http\middleware;
use app\adminapi\logic\LoginLogic;
use app\common\cache\AdminTokenCache;
use app\adminapi\service\AdminTokenService;
use app\common\service\JsonService;
use think\facade\Config;
/**
* 登录中间件
* Class LoginMiddleware
* @package app\adminapi\http\middleware
*/
class LoginMiddleware
{
/**
* @notes 登录验证
* @param $request
* @param \Closure $next
* @return mixed|\think\response\Json
* @author 令狐冲
* @date 2021/7/1 17:33
*/
public function handle($request, \Closure $next)
{
$token = $request->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;
}
}
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\adminapi\http\middleware;
use app\adminapi\logic\LoginLogic;
use app\common\cache\AdminTokenCache;
use app\common\enum\AdminTerminalEnum;
use app\adminapi\service\AdminTokenService;
use app\common\service\JsonService;
/**
* 登录中间件
* Class LoginMiddleware
* @package app\adminapi\http\middleware
*/
class LoginMiddleware
{
/**
* @notes 登录验证
* @param $request
* @param \Closure $next
* @return mixed|\think\response\Json
* @author 令狐冲
* @date 2021/7/1 17:33
*/
public function handle($request, \Closure $next)
{
$token = $request->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;
}
}
+362 -358
View File
@@ -1,358 +1,362 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\adminapi\logic;
use app\common\logic\BaseLogic;
use app\common\model\auth\Admin;
use app\adminapi\service\AdminTokenService;
use app\common\service\FileService;
use think\facade\Config;
use think\facade\Cache;
use think\facade\Log;
/**
* 登录逻辑
* Class LoginLogic
* @package app\adminapi\logic
*/
class LoginLogic extends BaseLogic
{
/** 非 root 未绑定企微时接口返回,与 axios 约定一致 */
public const CODE_NEED_BIND_WORK_WECHAT = 10;
/**
* 企业微信网页授权 / 扫码绑定所需配置是否齐全
*/
public static function isWorkWechatOAuthConfigured(): bool
{
$corpId = (string) env('work_wechat.corp_id', '');
$secret = (string) env('work_wechat.secret', '');
$agentId = (string) env('work_wechat.agent_id', '');
return $corpId !== '' && $secret !== '' && $agentId !== '';
}
/**
* .env 是否开启「非 root 须绑定企微」。
* 推荐在 [work_wechat] 下写 FORCE_BIND_LOGIN=true;勿在节内写 WORK_WECHAT_FORCE_BIND_LOGIN(会变成双前缀键读不到)。
*/
public static function isForceBindWorkWechatFromEnv(): bool
{
$candidates = [
env('WORK_WECHAT_FORCE_BIND_LOGIN', false),
env('work_wechat.force_bind_login', false),
env('work_wechat.work_wechat_force_bind_login', false),
];
$v = false;
foreach ($candidates as $c) {
if ($c !== false && $c !== null && $c !== '') {
$v = $c;
break;
}
}
if (is_bool($v)) {
return $v;
}
$v = strtolower(trim((string) $v));
return in_array($v, ['1', 'true', 'yes', 'on'], true);
}
/**
* .env 开启强制绑定 + 企微 OAuth 已配置 + 非 root + 未绑定 work_wechat_userid
*
* @param array{root?:int|string,work_wechat_userid?:string} $adminInfo
*/
public static function adminMustBindWorkWechat(array $adminInfo): bool
{
if (!self::isForceBindWorkWechatFromEnv()) {
return false;
}
if (!self::isWorkWechatOAuthConfigured()) {
return false;
}
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return false;
}
return trim((string) ($adminInfo['work_wechat_userid'] ?? '')) === '';
}
/**
* 开启「须绑定企微」时,这些 action 仍须放行(与 $request->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;
}
}
}
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\adminapi\logic;
use app\common\logic\BaseLogic;
use app\common\model\auth\Admin;
use app\adminapi\service\AdminTokenService;
use app\common\service\FileService;
use think\facade\Config;
use think\facade\Cache;
use think\facade\Log;
use think\facade\Db;
/**
* 登录逻辑
* Class LoginLogic
* @package app\adminapi\logic
*/
class LoginLogic extends BaseLogic
{
/** 非 root 未绑定企微时接口返回,与 axios 约定一致 */
public const CODE_NEED_BIND_WORK_WECHAT = 10;
/**
* 企业微信网页授权 / 扫码绑定所需配置是否齐全
*/
public static function isWorkWechatOAuthConfigured(): bool
{
$corpId = (string) env('work_wechat.corp_id', '');
$secret = (string) env('work_wechat.secret', '');
$agentId = (string) env('work_wechat.agent_id', '');
return $corpId !== '' && $secret !== '' && $agentId !== '';
}
/**
* .env 是否开启「非 root 须绑定企微」。
* 推荐在 [work_wechat] 下写 FORCE_BIND_LOGIN=true;勿在节内写 WORK_WECHAT_FORCE_BIND_LOGIN(会变成双前缀键读不到)。
*/
public static function isForceBindWorkWechatFromEnv(): bool
{
$candidates = [
env('WORK_WECHAT_FORCE_BIND_LOGIN', false),
env('work_wechat.force_bind_login', false),
env('work_wechat.work_wechat_force_bind_login', false),
];
$v = false;
foreach ($candidates as $c) {
if ($c !== false && $c !== null && $c !== '') {
$v = $c;
break;
}
}
if (is_bool($v)) {
return $v;
}
$v = strtolower(trim((string) $v));
return in_array($v, ['1', 'true', 'yes', 'on'], true);
}
/**
* .env 开启强制绑定 + 企微 OAuth 已配置 + 非 root + 未绑定 work_wechat_userid
*
* @param array{root?:int|string,work_wechat_userid?:string} $adminInfo
*/
public static function adminMustBindWorkWechat(array $adminInfo): bool
{
if (!self::isForceBindWorkWechatFromEnv()) {
return false;
}
if (!self::isWorkWechatOAuthConfigured()) {
return false;
}
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return false;
}
return trim((string) ($adminInfo['work_wechat_userid'] ?? '')) === '';
}
/**
* 开启「须绑定企微」时,这些 action 仍须放行(与 $request->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;
}
}
}
+11 -1
View File
@@ -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;
});
}
+177 -132
View File
@@ -1,132 +1,177 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\adminapi\service;
use app\common\cache\AdminTokenCache;
use app\common\model\auth\AdminSession;
use think\facade\Config;
/**
* 管理员token
* Class AdminTokenService
* @package app\adminapi\service
*/
class AdminTokenService
{
/**
* @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 + 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);
}
}
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\adminapi\service;
use app\common\cache\AdminTokenCache;
use app\common\enum\AdminTerminalEnum;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminSession;
use think\facade\Config;
/**
* 管理员token
* Class AdminTokenService
* @package app\adminapi\service
*/
class AdminTokenService
{
public static function expireDuration($terminal): int
{
return (int) Config::get((int) $terminal === AdminTerminalEnum::BCR
? 'project.admin_token.bcr_expire_duration' : 'project.admin_token.expire_duration',
(int) $terminal === AdminTerminalEnum::BCR ? 86400 * 180 : 3600 * 8);
}
public static function renewalWindow($terminal): int
{
return (int) Config::get((int) $terminal === AdminTerminalEnum::BCR
? 'project.admin_token.bcr_be_expire_duration' : 'project.admin_token.be_expire_duration',
(int) $terminal === AdminTerminalEnum::BCR ? 86400 * 150 : 3600);
}
/** 与改密同一事务调用;只撤销 BCR,会话有效性由 DB 决定,旧缓存不能续活。 */
public static function revokeBcrSessions(int $adminId): void
{
AdminSession::where('admin_id', $adminId)->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);
}
}
@@ -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',
];
+142 -120
View File
@@ -1,120 +1,142 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\cache;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminSession;
use app\common\model\auth\SystemRole;
/**
* 管理员token缓存
* Class AdminTokenCache
* @package app\common\cache
*/
class AdminTokenCache extends BaseCache
{
private $prefix = 'token_admin_';
/**
* @notes 通过token获取缓存管理员信息
* @param $token
* @return false|mixed
* @author 令狐冲
* @date 2021/6/30 16:57
*/
public function getAdminInfo($token)
{
//直接从缓存获取
$adminInfo = $this->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);
}
}
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\cache;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminSession;
use app\common\model\auth\SystemRole;
use app\common\enum\AdminTerminalEnum;
/**
* 管理员token缓存
* Class AdminTokenCache
* @package app\common\cache
*/
class AdminTokenCache extends BaseCache
{
private $prefix = 'token_admin_';
/**
* @notes 通过token获取缓存管理员信息
* @param $token
* @return false|mixed
* @author 令狐冲
* @date 2021/6/30 16:57
*/
public function getAdminInfo($token)
{
//直接从缓存获取
$adminInfo = $this->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);
}
}
@@ -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)占用,不要复用。
+3
View File
@@ -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(登录令牌)配置
+340
View File
@@ -0,0 +1,340 @@
<?php
declare(strict_types=1);
// Real service, token cache, middleware, ORM and account mutation classes; synthetic SQLite only.
// Optional argv[1] supplies an unchanged server directory for dependencies when testing a staged patch.
// No App::initialize(), .env, application config, network or filesystem-backed cache is used.
namespace {
final class BcrClock { public static int $now = 1900000000; }
}
namespace app\adminapi\service { function time(): int { return \BcrClock::$now; } }
namespace app\common\cache {
function time(): int { return \BcrClock::$now; }
abstract class BaseCache {
public static array $entries = [];
public static bool $keepDeleted = false;
public function __construct() {}
public function get($key) {
$entry = self::$entries[$key] ?? null;
return $entry && ($entry['until'] === null || $entry['until'] > \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";
}