// +---------------------------------------------------------------------- declare(strict_types=1); namespace ywxapp\library; use think\App; use think\exception\HttpException; use think\facade\Cookie; use think\facade\Db; use think\facade\Session; use think\facade\Validate; use ywxapp\library\Result; use ywxapp\model\BackendAdmin; use ywxapp\model\MemberUser as UserModel; use ywxapp\service\JwtService; use ywxapp\utils\Random; /** * 前台(会员/API)鉴权类 * * 负责会员中心的认证:按 Member 表(主键 uid)查用户、解析会员角色/权限, * 以及登录/注册/改密等前台业务逻辑。 * 后台鉴权由 AdminAuth 继承本类,仅重写差异钩子(不同模型、权限集、登录事件)。 */ class Auth { protected $app; /** * 是否登录 * @var bool */ protected $isLogin = false; /** * 是否管理员? */ protected $isAdmin = false; /** * 当前登录用户信息 * @var \ywxapp\model\Member */ protected $model; /** * 个人信息 * @var \ywxapp\model\Member */ protected $info; /** * 权限规则 * @var array */ protected $powers; /** * 角色 * @var array */ protected $roles; /** * 用户资料,用于临时存放一些用户数据,比如用户IP地址,需要使用后销毁 * @var array */ protected $profile; /** * 允许查询的字段(前台) * @var array */ protected $allowFields = ['uid', 'account', 'nickname', 'roles', 'avatar', 'score']; /** * 构造方法 * @param App $app */ public function __construct(App $app, $options = []) { $this->app = $app; } /** * 尝试用 access_token 解析并登录(对外可独立调用,如中间件)。 * * 设计原则:**有 token 才解析并登录,没有 token 则静默跳过**(不报错、不登录), * 从而天然支持「访客访问无需登录的页面」与「携带 token 自动登录」两种场景。 * * @return bool 是否成功还原登录态 */ public function tryInitByToken(): bool { // 已登录则直接返回,避免重复解析与重复 initUser if ($this->isLogin) { return true; } $tokenStr = $this->resolveAccessToken(); if (empty($tokenStr)) { // 没有 token:不解析、不登录 return false; } try { $claims = $this->parseClaims($tokenStr); // 上下文一致性校验:token 的 isAdmin 声明必须与当前 auth 类型匹配 // (前台 isAdmin=false / 后台 AdminAuth isAdmin=true),防止前后台 token 串用越权。 if (($claims['isAdmin'] ?? false) !== $this->isAdmin) { throw new \Exception('auth context mismatch'); } $this->initUser($claims['uid']); return true; } catch (\Exception $e) { // token 无效/过期:清除解析缓存 \think\facade\Cache::delete('auth_token_' . md5($tokenStr)); // 尝试用 refresh_token 自动续期(整页/iframe 场景也能续期,避免被踢回登录页) $this->tryRefresh(); return $this->isLogin; } } /** * 登录态校验(供后台/前台/会员中心控制器层 verifyAuth 统一调用) * 先尝试用 token 还原登录态,再返回是否登录。 */ public function checkLogin(): bool { $this->tryInitByToken(); return (bool) $this->isLogin; } /** * 鉴权前置:尝试用 token 还原登录态(兼容旧调用点 verifyAuth / checkLogin)。 */ protected function tokenParse() { $this->tryInitByToken(); } /** * 初始化登录用户(模板方法) * 公共流程:按主键查模型 → 登录连续性统计 → 解析角色/权限 → 标记登录态 → 触发事件。 * 与具体模型/角色/权限相关、前后台不同的部分,下沉到下方 protected 钩子, * 从而天然适配前后台不同的主键(admin=id / user=uid)与不同的权限体系。 * * @param mixed $id 用户主键(admin 为 id,user 为 uid) * @return static */ public function initUser($id) { $modelClass = $this->getUserModel(); $info = $modelClass::findOrEmpty($id); if ($info->isEmpty()) { Result::instance()->error($this->notFoundMessage($id)); } Db::startTrans(); try { if ($info->update_at < \ywxapp\utils\Date::unixtime('day')) { $info->successions = $info->logintime < \ywxapp\utils\Date::unixtime( 'day', -1 ) ? 1 : $info->successions + 1; $info->maxsuccessions = max($info->successions, $info->maxsuccessions); } $info->save(); $this->roles = $this->resolveRoles($info); $this->powers = $this->resolvePowers($info); $this->isLogin = true; $this->info = $info->toArray(); $this->model = $info; $this->afterLogin($info); Db::commit(); } catch (\think\Exception $e) { Db::rollback(); throw new HttpException(4001, $e->getMessage()); } return $this; } /** * 返回当前上下文的用户模型类(核心 PK 适配钩子)。 * 前台(Auth)默认 Member 模型(主键 uid);后台(AdminAuth)重写为 Backend 模型(主键 id)。 */ protected function getUserModel(): string { return UserModel::class; } /** * 根据模型实例解析角色集合。前台会员角色取自 groups 字段;后台在 AdminAuth 重写为 roles。 */ protected function resolveRoles($info) { return $info->groups ?? []; } /** * 根据模型实例解析权限集。前台会员默认无后台式权限集,由控制器 noNeedVerify 控制访问。 */ protected function resolvePowers($info): array { return []; } /** * 用户不存在时的错误提示 */ protected function notFoundMessage($id): string { return "UserId:$id is incorrect"; } /** * 登录成功后的事件钩子(前台触发 UserLogined) */ protected function afterLogin($info): void { event('UserLogined', $this->info); } /** * 退出登录后的钩子。前台会员:清空整个 session 并触发退出事件(由 Auth::logout 调用)。 * 后台 AdminAuth 重写为空(仅清 token,保持与原 Backend 行为一致)。 */ protected function afterLogout(): void { \think\facade\Session::clear(); event('user_logout_after', $this->model); } /** * 解析 access_token 的 claims(带缓存) * @param string $tokenStr * @return array */ protected function parseClaims(string $tokenStr): array { $cacheKey = 'auth_token_' . md5($tokenStr); $claims = \think\facade\Cache::get($cacheKey); if (!$claims) { $token = JwtService::instance()->parseAndValidate($tokenStr); $claims = $token->claims()->all(); \think\facade\Cache::set($cacheKey, $claims, 300); } return $claims; } /** * 从 header / Session / Cookie 中解析当前 access_token */ protected function resolveAccessToken(): string { $authHeader = $this->app->request->header('authorization'); if (!empty($authHeader) && str_starts_with($authHeader, 'Bearer ')) { return (string) substr($authHeader, 7); } $token = Session::get('access_token'); if (!empty($token)) { return (string) $token; } return (string) \think\facade\Cookie::get('access_token'); } /** * 解析可用于续期的 refresh_token(header / Session / Cookie) */ protected function resolveRefreshToken(): string { $authHeader = $this->app->request->header('authorization_refresh'); if (!empty($authHeader) && str_starts_with($authHeader, 'Bearer ')) { return (string) substr($authHeader, 7); } $token = Session::get('refresh_token'); if (!empty($token)) { return (string) $token; } return (string) \think\facade\Cookie::get('refresh_token'); } /** * 使用 refresh_token 自动续期 access_token。 * 用于整页/iframe 等非 ajax 场景:access_token 过期但 refresh_token 仍有效时, * 后端透明生成新 access_token 并写回 Session / Cookie,避免子页面被踢回登录页。 */ protected function tryRefresh(): void { $refreshToken = $this->resolveRefreshToken(); if (empty($refreshToken)) { return; } try { $data = JwtService::instance()->refreshAccessToken($refreshToken); $newToken = $data['access_token'] ?? ''; if (empty($newToken)) { return; } Session::set('access_token', $newToken); // 同步写入非 httpOnly cookie,便于前端与 iframe 后续请求携带 \think\facade\Cookie::set('access_token', $newToken, ['httponly' => false, 'path' => '/']); $claims = JwtService::instance()->parseAndValidate($newToken)->claims()->all(); // 续期得到的 token 也必须与当前上下文一致,否则视为无效 if (($claims['isAdmin'] ?? false) !== $this->isAdmin) { return; } $this->initUser($claims['uid']); } catch (\Exception $e) { // 续期失败,保持未登录,交由 verifyAuth 返回 401 } } /** * 用户权限验证 */ public function verifyAuth($noNeedLogin = [], $noNeedRight = []) { // 先尝试用 token 还原登录态:登录态由 tryInitByToken 写入 $this->isLogin, // 必须由本方法自行触发,不能依赖外部(核心后台靠容器单例的历史初始化、 // 插件后台 new AdminAuth 全新实例都不可靠,会导致 isLogin 恒为默认 false 而误踢登录)。 $this->tokenParse(); $request = app()->request; $action = strtolower($request->action()); if (in_array('*', $noNeedLogin) || in_array($action, $noNeedLogin)) { return true; } // 规范化权限集合,避免 initUser 未赋值导致 in_array() 报 TypeError $this->powers = $this->powers ?? []; if ($this->isLogin == false) { // 整页(非 AJAX)请求未登录:直接 302 跳转到登录页; // AJAX 请求返回 401 JSON,交给前端 kernel.js 跳转,避免整页被踢。 if (! $request->isAjax() && ! $request->isPjax()) { // 直接发送 302 并终止,确保构造期(_initialize 中调用)也能可靠跳转 $this->redirectToLogin(); } Result::instance()->setStatusCode(401)->error(lang('Please login first')); } // 超级管理员直接放行(避免权限数据缺失时锁定后台) if (! empty($this->model) && ! empty($this->model->id) && $this->model->id == config('ywxapp.superAdmin', 1)) { return true; } // 权限集合含通配符 * 表示拥有全部权限(如超级管理员角色),直接放行 if (in_array('*', $this->powers, true)) { return true; } if (in_array('*', $noNeedRight) || in_array($action, $noNeedRight)) { return true; } $controller = strtolower($request->controller()); $path = str_replace('.', ':', $controller) . ':' . $action; // 精确匹配 或 控制器级通配(controller:*) if (in_array($path, $this->powers) || in_array($controller . ':*', $this->powers)) { return true; } Result::instance()->setStatusCode(403)->error(lang('You have no permission')); } /** * 跳转到登录页(生成完整绝对 URL 后 302)。 * * 登录跳转链接使用「当前请求域名 + 子目录(root) + 登录路径」拼成绝对地址, * 避免在子目录部署或中心站/客户机跨域分发时跳到错误位置。 * 后台路径跟随 config/app.php 的 app_map 中 backend 对应的别名(默认 admin), * 前台为 /Login。config/ywxapp.php 的 backend_login_url / member_login_url 可强制覆盖。 * * @return void */ /** * 从 app 配置推导后台应用的 URL 别名(app_map 中 backend 对应的键)。 * * 多应用模式下后台真实目录为 backend,对外 URL 段由 config/app.php 的 * app_map['<别名>'=>'backend'] 决定(默认 admin)。硬编码 /admin 会在别名 * 调整时失效,故反查配置,使登录跳转跟随部署配置。 * * @return string 如 'backend' */ protected function UrlAlias($val): string { $appMap = config('app.app_map', []); $alias = array_search($val, $appMap, true); return $alias === false ? $val : (string) $alias; } public function redirectToLogin() { if ($this->isAdmin) { // 优先读 config/ywxapp.php 的 backend_login_url 覆盖;留空则跟随 app_map 推导 $override = trim((string) config('ywxapp.backend_login_url', '')); $path = $override !== '' ? $override : '/' . $this->UrlAlias('backend') . '/Login/index'; } else { $override = trim((string) config('ywxapp.member_login_url', '')); $path = $override !== '' ? $override : '/' . $this->UrlAlias('member') . '/Login/index'; } // 用 url() 助手生成完整绝对 URL(自动带域名 + 子目录 root), // 避免在子目录部署或中心站/客户机跨域分发时跳到错误位置(原先为裸根路径)。 $loginUrl = (string) url($path, [], false, true); // 直接发送 302 并终止,确保构造期(_initialize 中调用)也能可靠跳转 redirect($loginUrl)->send(); exit; } /** * 登录成功后把 access/refresh token 持久化到共享 cookie(path=/) 与服务端 session, * 保证整页跳转(iframe、菜单点击等非 AJAX 场景,不会自动带 Authorization 头) * 也能通过 cookie/session 还原登录态,避免被踢回登录页。 * @param array $tokens JwtService::createToken() 的返回值 */ protected function persistTokens(array $tokens): void { $at = $tokens['access_token'] ?? ''; $rt = $tokens['refresh_token'] ?? ''; if ($at !== '') { Session::set('access_token', $at); \think\facade\Cookie::set('access_token', $at, ['httponly' => false, 'path' => '/']); } if ($rt !== '') { Session::set('refresh_token', $rt); \think\facade\Cookie::set('refresh_token', $rt, ['httponly' => false, 'path' => '/']); } } /** * 用户登出,清除 token 缓存与登录态 * @return void */ public function logout() { $tokenStr = $this->resolveAccessToken(); // 子类钩子:此时 model/info 尚未清空,可做事件触发、整 session 清除等 $this->afterLogout(); if ($tokenStr) { \think\facade\Cache::delete('auth_token_' . md5($tokenStr)); } // 清除服务端 session / cookie 中的 token Session::delete('access_token'); Session::delete('refresh_token'); \think\facade\Cookie::delete('access_token'); \think\facade\Cookie::delete('refresh_token'); // 重置登录状态 $this->isLogin = false; $this->info = null; $this->model = null; $this->powers = []; $this->roles = []; } /** * 魔术读取(兼容 $auth->info / $auth->model 等) * @param mixed $name * @return mixed */ public function __get($name) { return $this->$name; } /** * 魔术赋值 * @param mixed $name * @param mixed $value * @return void */ public function __set($name, $value) { $this->$name = $value; } public static function instance($options = []) { return app()->auth; } /** * 注册用户(前台) * * @param string $account 用户名 * @param string $password 密码 * @param string $email 邮箱 * @param string $mobile 手机号 * @param array $extend 扩展参数 * @return boolean */ public function register($account = '', $password = '', $email = '', $mobile = '', $extend = []) { //账号注册时需要开启事务,避免出现垃圾数据 Db::startTrans(); try { $account = $account ?: Random::account() . mb_substr($mobile ?: strtoupper(Random::alnum(4)), -4); $password = $password ?: Random::alnum(16); $nickname = $extend['nickname'] ?? '用户' . mb_substr($mobile ?: strtoupper(Random::alnum(4)), -4); // 检测用户名 if (UserModel::checkExists('account', $account)) { Result::instance()->error('Account already exist'); Db::rollback(); return false; } // 检测邮箱 if ($email && UserModel::checkExists('email', $email)) { Result::instance()->error('Email already exist'); Db::rollback(); return false; } // 检测手机号 if ($mobile && UserModel::checkExists('mobile', $mobile)) { Result::instance()->error('Mobile already exist'); Db::rollback(); return false; } $ip = request()->ip(); $data = [ 'gid' => config('fastadmin.user_default_group') ?: 0, 'account' => $account, 'password' => $password, 'email' => $email, 'mobile' => $mobile, //'score' => config('fastadmin.user_initial_score') ?: 0, 'avatar' => '', 'nickname' => $nickname, 'create_ip' => $ip, 'update_ip' => $ip, 'status' => 1, ]; $params['password'] = $this->getEncryptPassword($password); $params = array_merge($params, $extend); $this->info = UserModel::create($params, true); $newClaims = [ 'uid' => $this->info->uid, 'account' => $this->info->account, ]; $tokens = JwtService::instance()->createToken($newClaims); $this->persistTokens($tokens); $this->isLogin = true; event('user_register_after', $this->info); Db::commit(); } catch (Exception $e) { $this->setError($e->getMessage()); Db::rollback(); return false; } return true; } /** * 用户登录(前台) * * @param string $account 账号,用户名、邮箱、手机号 * @param string $password 密码 * @return boolean */ public function login($account, $password, $isAuthPass = true) { $field = Validate::checkRule($account, 'email') ? 'email' : (Validate::checkRule($account, 'mobile') ? 'mobile' : 'account'); $info = UserModel::where($field, $account)->findOrEmpty(); // 会员表查无此账号时,回退到后台管理员表校验(打通会员中心与后台同一账号登录) if ($info->isEmpty()) { $admin = BackendAdmin::where('account', $account)->find(); if ($admin && $admin->status == 1 && $admin->checkPassword($password)) { $info = $this->syncFromAdmin($admin, $password); } } if ($info->isEmpty()) { Result::instance()->error('Account is incorrect'); return false; } if ($info->status != 1) { Result::instance()->error('Member Account is locked'); return false; } if ($info->isLocked()) { Result::instance()->error('Member Account is locked'); return false; } $info->resetPassword($password); if (! $info->checkPassword($password)) { $info->recordLoginFail($_SERVER['REMOTE_ADDR'] ?? ''); Result::instance()->error('Password is incorrect'); return false; } $newClaims = [ 'uid' => $info->uid, 'account' => $info->account, ]; // createToken 同时生成 access/refresh;持久化到共享 cookie(path=/)+session, // 使整页跳转(iframe/菜单点击,无 Authorization 头)也能还原登录态。 $tokens = JWTService::instance()->createToken($newClaims); $this->persistTokens($tokens); // 钩子点:会员/API 登录成功后触发,插件可在 info.php['events']['listen'] 中监听 user_login_after event('user_login_after', $info); $info->recordLogin($_SERVER['REMOTE_ADDR'] ?? ''); $this->initUser($info->uid); } /** * 后台管理员账号首次登录会员中心时,在会员表自动创建一条对应记录, * 密码按会员表规则(带 salt)加密,以便后续会员中心可独立登录。 * 仅在会员表无该 account 时创建。 * * @param \ywxapp\model\Backend $admin 后台管理员模型(已通过密码校验) * @param string $password 明文密码(用于会员表加密存储) * @return \ywxapp\model\Member */ protected function syncFromAdmin($admin, $password) { $user = UserModel::where('account', $admin->account)->findOrEmpty(); if (! $user->isEmpty()) { return $user; } $user = UserModel::create([ 'account' => $admin->account, 'nickname' => $admin->nickname ?: $admin->account, 'password' => $password, // 触发 Member 模型的 setPasswordAttr 自动加盐加密 'gid' => config('fastadmin.user_default_group') ?: 0, 'status' => 1, 'create_ip' => request()->ip(), 'update_ip' => request()->ip(), ]); return $user; } /** * 修改密码(前台) * @param string $newpassword 新密码 * @param string $oldpassword 旧密码 * @param bool $ignoreoldpassword 忽略旧密码 * @return boolean */ public function changepwd($newpassword, $oldpassword = '', $ignoreoldpassword = false) { if (! $this->_logined) { $this->setError('You are not logged in'); return false; } //判断旧密码是否正确 if ($this->_user->password == $this->getEncryptPassword($oldpassword, $this->_user->salt) || $ignoreoldpassword) { Db::startTrans(); try { $salt = Random::alnum(); $newpassword = $this->getEncryptPassword($newpassword, $salt); $this->_user->save(['loginfailure' => 0, 'password' => $newpassword, 'salt' => $salt]); Token::clear($this->_user->uid); //修改密码成功的事件 event('user_changepwd_after', $this->_user); Db::commit(); } catch (Exception $e) { Db::rollback(); $this->setError($e->getMessage()); return false; } return true; } else { $this->setError('Password is incorrect'); return false; } } }