chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+671
View File
@@ -0,0 +1,671 @@
<?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;
}
}
}