Files
YwxAppThink/ywxapp/library/AdminAuth.php
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

368 lines
13 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\Db;
use think\facade\Event;
use think\facade\Validate;
use ywxapp\library\Result;
use ywxapp\model\BackendAdmin as AdminModel;
use ywxapp\service\JwtService;
use ywxapp\utils\Random;
/**
* 后台(管理员)鉴权类,继承前台 Auth,仅重写差异钩子与业务逻辑。
*
* 差异点:
* - 模型:Backend 模型(主键 id)
* - 角色字段:roles;权限集:getPermissionNames()
* - 登录事件:user_login_successed
* - 登出:不清整个 session(仅清 token),保持与原 Backend 行为一致
* - 登录/注册/改密:后台业务逻辑(查 Backend 表)
*/
class AdminAuth extends Auth
{
/**
* 超级管理员
*/
protected $isSuperAdmin = false;
/**
* 超级管理员
*/
private $superAdmin = 1;
/**
* 允许查询的字段(后台)
* @var array
*/
protected $allowFields = ['id', 'account', 'nickname', 'roles', 'avatar', 'score'];
/**
* 构造方法
* @param App $app
*/
public function __construct(App $app, $options = [])
{
parent::__construct($app, $options);
$this->isAdmin = true; // 标记后台管理员上下文,供 tryLoginByToken 做 isAdmin 一致性校验
}
/**
* 返回管理员模型类(主键为 id)
*/
protected function getUserModel(): string
{
return AdminModel::class;
}
/**
* 后台角色取自 roles 字段(前台 Auth 默认取 groups,此处重写)
*/
protected function resolveRoles($info)
{
return $info->roles ?? [];
}
/**
* 管理员权限取自模型方法(后台式权限集)
*/
protected function resolvePowers($info): array
{
return $info->getPermissionNames();
}
/**
* 管理员不存在时的错误提示
*/
protected function notFoundMessage($id): string
{
return "AdminId:$id is incorrect";
}
/**
* 管理员登录成功后触发事件
*/
protected function afterLogin($info): void
{
Event::trigger('user_login_successed', $this->info);
}
/**
* 后台登出:仅清 token(不清整个 session),保持与原 Backend 行为一致
*/
protected function afterLogout(): void {}
/**
* 添加管理员(后台).
*
* @param string $username 用户名
* @param string $password 密码
* @param array $extend 扩展参数
* @return void
*/
public function register($account = '', $password = '', $email = '', $mobile = '', $extend = [])
{
AdminModel::ensureSchema(); // 后台核心表自愈(缺表/缺列兜底)
if (AdminModel::getByAccount($account)) {
Result::instance()->error('Account already exist');
}
$data = [
'password' => password_hash($password ? $password : Random::alpha(6), PASSWORD_DEFAULT, ['cost' => 12]),
'status' => 0,
't1' => $account,
];
$field = Validate::is($account, 'email') ? 'email' : (Validate::is($account, 'mobile') ? 'mobile' : 'account');
$data[$field] = $account;
$data = array_merge($data, $extend);
$params = Event::trigger('AdminBeforeRegister', $data, true);
$data = array_merge($data, $params);
Db::startTrans();
try {
$info = AdminModel::create($data);
$this->info = AdminModel::find($info->id);
Event::trigger('AdminAfterRegister', $this->info);
Db::commit();
$newClaims = [
'uid' => $info->id,
'isAdmin' => true,
'account' => $info->account,
];
$tokens = JwtService::instance()->createToken($newClaims);
$this->persistTokens($tokens);
} catch (\think\Exception $e) {
Db::rollback();
Result::instance()->error($e->getMessage());
}
}
/**
* 管理员登录(后台).
*
* @param string $account 账号,用户名、邮箱、手机号
* @param string $password 密码
* @return void
*/
/**
* 管理员登录(后台).
*
* 安全加固(P0):
* 1) 限流:同 IP + 同账号双维度计数,超限直接关小黑屋,堵住密码爆破与账号枚举;
* 注意 ratelimit.php 的 RateLimit 中间件只注册在 api 应用,后台 /admin/login 走不到,
* 因此这里必须自带计数,不能指望中间件。
* 2) 锁定:BackendAdmin::isLocked() 早就有实现,但此前从未被登录流程调用,
* 导致 fail_count / lock_time 写了却不起作用,这里补上。
* 3) 验证码:$captcha 以前被塞进同名参数 $isAuthPass 后被静默丢弃,等于没有验证码。
*
* @param string $account 账号,用户名、邮箱、手机号
* @param string $password 密码
* @param string $captcha 用户输入的验证码
* @param string $captchaToken CaptchaService 生成的一次性 token(无状态验证码,APP/SPA 用)
* @return void
*/
public function login($account, $password, $captcha = '', $captchaToken = '')
{
AdminModel::ensureSchema(); // 后台核心表自愈(缺表/缺列兜底)
$ip = (string) $this->app->request->ip();
// 1) 限流闸门(IP + 账号)
$this->assertNotRateLimited($ip, (string) $account);
$field = Validate::is($account, 'email') ? 'email' : (Validate::regex(
$account,
'/^1\d{10}$/'
) ? 'mobile' : 'account');
$info = AdminModel::where([$field => $account])->find();
if (! $info) {
// 账号不存在也计入失败:否则攻击者可用大量不存在的账号无成本枚举
$this->markLoginFail($ip, (string) $account);
Result::instance()->error('Account is incorrect');
}
// 2) 账号级锁定(连续失败 5 次锁 30 分钟)
if ($info->isLocked()) {
Result::instance()->error('密码错误次数过多,账号已被锁定,请 30 分钟后重试', 4011);
}
// 3) 验证码(必须在密码校验之前,否则验证码形同虚设)
$this->assertCaptcha((string) $captcha, (string) $captchaToken);
// 4) 验证密码(直接使用库中存储的哈希,禁止先 resetPassword)
if (! $info->checkPassword($password)) {
$info->recordLoginFail($ip); // 记录失败(累计到 5 次会写 lock_time)
$this->markLoginFail($ip, (string) $account);
Result::instance()->error('密码错误', 4011);
}
$this->clearLoginFail($ip, (string) $account);
$info->recordLoginSuccess();
$newClaims = [
'uid' => $info->id,
'isAdmin' => true,
'role' => 'admin',
'account' => $info->account,
];
$tokens = JwtService::instance()->createToken($newClaims);
$this->persistTokens($tokens);
$this->initUser($info->id);
}
/* ==================== 登录防护:限流 / 验证码 ==================== */
/**
* 登录失败计数 key(IP + 账号)
*/
protected function loginFailKey(string $ip, string $account): string
{
return 'admin_login_fail_' . md5($ip . '|' . strtolower($account));
}
/**
* 登录封禁 key(达到阈值后置位,窗口内一律拒绝)
*/
protected function loginBlockKey(string $ip, string $account): string
{
return 'admin_login_block_' . md5($ip . '|' . strtolower($account));
}
/**
* 是否处于封禁窗口内
*/
protected function assertNotRateLimited(string $ip, string $account): void
{
$limit = (int) \think\facade\Config::get('ywxapp.login_fail_limit', 10);
$window = (int) \think\facade\Config::get('ywxapp.login_fail_window', 600);
$block = (int) \think\facade\Config::get('ywxapp.login_block_time', 900);
if ($limit <= 0) {
return; // 显式关闭
}
if (\think\facade\Cache::get($this->loginBlockKey($ip, $account))) {
\think\facade\Log::warning('[AdminAuth] 登录被限流拦截', ['ip' => $ip, 'account' => $account]);
Result::instance()->error('登录失败次数过多,请 ' . (int) ($block / 60) . ' 分钟后再试', 4291);
}
}
/**
* 记录一次登录失败;达到阈值则封禁
*/
protected function markLoginFail(string $ip, string $account): void
{
$limit = (int) \think\facade\Config::get('ywxapp.login_fail_limit', 10);
$window = (int) \think\facade\Config::get('ywxapp.login_fail_window', 600);
$block = (int) \think\facade\Config::get('ywxapp.login_block_time', 900);
if ($limit <= 0) {
return;
}
$key = $this->loginFailKey($ip, $account);
$num = (int) \think\facade\Cache::get($key, 0) + 1;
\think\facade\Cache::set($key, $num, $window);
if ($num >= $limit) {
\think\facade\Cache::set($this->loginBlockKey($ip, $account), time(), $block);
\think\facade\Log::warning('[AdminAuth] 登录失败达到阈值,已封禁', [
'ip' => $ip, 'account' => $account, 'times' => $num,
]);
}
}
/**
* 登录成功后清零失败计数
*/
protected function clearLoginFail(string $ip, string $account): void
{
\think\facade\Cache::delete($this->loginFailKey($ip, $account));
\think\facade\Cache::delete($this->loginBlockKey($ip, $account));
}
/**
* 验证码校验(ywxapp.login_captcha 关闭时不校验)
*
* 两种形态都支持:
* - CaptchaService:无状态 token + code(小程序 / APP / SPA)
* - think-captcha:session 形态(后台登录页用的是这种)
*
* @param string $code 用户输入的验证码
* @param string $token CaptchaService 的 token;非空时优先走它
*/
protected function assertCaptcha(string $code, string $token = ''): void
{
if (! (bool) \think\facade\Config::get('ywxapp.login_captcha', true)) {
return;
}
if ($token !== '' && class_exists(\ywxapp\service\CaptchaService::class)) {
try {
if (\ywxapp\service\CaptchaService::instance()->verify($token, $code)) {
return;
}
} catch (\Throwable $e) {
// 降级到 session 形态
}
Result::instance()->error('验证码不正确', 4009);
}
if ($code === '') {
Result::instance()->error('请输入验证码', 4009);
}
$ok = false;
if (function_exists('captcha_check')) {
$ok = (bool) captcha_check($code);
} elseif (class_exists(\think\captcha\facade\Captcha::class)) {
try {
$ok = (bool) \think\captcha\facade\Captcha::check($code);
} catch (\Throwable $e) {
$ok = false;
}
}
if (! $ok) {
Result::instance()->error('验证码不正确', 4009);
}
}
/**
* 修改密码(后台)
* @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);
//修改密码成功的事件
Hook::listen("user_changepwd_successed", $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;
}
}
}