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(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+147 -6
View File
@@ -155,26 +155,58 @@ class AdminAuth extends Auth
* @param string $password 密码
* @return void
*/
public function login($account, $password, $isAuthPass = true)
/**
* 管理员登录(后台).
*
* 安全加固(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');
}
if ($info->status != 1) {
Result::instance()->error('Account is locked');
// 2) 账号级锁定(连续失败 5 次锁 30 分钟)
if ($info->isLocked()) {
Result::instance()->error('密码错误次数过多,账号已被锁定,请 30 分钟后重试', 4011);
}
$info->resetPassword($password);
// 验证密码(直接使用库中存储的哈希,禁止先 resetPassword)
// 3) 验证码(必须在密码校验之前,否则验证码形同虚设)
$this->assertCaptcha((string) $captcha, (string) $captchaToken);
// 4) 验证密码(直接使用库中存储的哈希,禁止先 resetPassword)
if (! $info->checkPassword($password)) {
$info->recordLoginFail($this->app->request->ip()); // 记录失败
$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,
@@ -187,6 +219,115 @@ class AdminAuth extends Auth
$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 新密码