安全(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(市场 / 开发者中心 / 审核 / 分润)
368 lines
13 KiB
PHP
368 lines
13 KiB
PHP
<?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;
|
||
}
|
||
}
|
||
}
|