// +---------------------------------------------------------------------- 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; } } }