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:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -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 新密码
|
||||
|
||||
Reference in new issue
Block a user