Files
YwxAppThink/ywxapp/library/Auth.php
T

672 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
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 为 iduser 为 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_tokenheader / 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;
}
}
}