gengx
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)占用,不要复用。
|
||||
|
||||
@@ -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(登录令牌)配置
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user