parseEquipment(); $browser = $ua->parseBrowser(); // 必须排除软删账号(deleted_at=0),否则被删管理员仍能用原密码登录 $userModel = AdminModel::with(['role:id,name,value'])->where('phone', $phone)->where('deleted_at', 0)->first(); if (empty($userModel)) { $this->writeLoginLog(0, (string) $phone, '', 1, '用户或密码错误', $equipment, $browser); UtilsService::getInstance()->errorThrow('用户或密码错误!'); } // 老后台是无盐 sha1,没有明文无法预先转 bcrypt,所以 bcrypt 校验失败时再回落比对遗留密码 $legacyHit = false; if (!password_verify($password, (string) $userModel->password)) { $legacyHit = $this->verifyLegacyPassword($userModel, (string) $password); if (!$legacyHit) { $this->writeLoginLog( (int) $userModel->id, (string) $phone, (string) $userModel->nick_name, 1, '用户或密码错误', $equipment, $browser ); UtilsService::getInstance()->errorThrow('用户或密码错误!'); } } // 状态校验放在密码校验之后,避免未通过认证就泄露账号是否存在或被禁用 if ((int) $userModel->status === 1) { $this->writeLoginLog( (int) $userModel->id, (string) $phone, (string) $userModel->nick_name, 1, '账号已被禁用', $equipment, $browser ); UtilsService::getInstance()->errorThrow('账号已被禁用,请联系管理员!'); } $updateData = [ 'last_login_time' => get_time(), 'updated_at' => get_time(), ]; if ($legacyHit) { // 命中遗留密码即刻升级成 bcrypt 并清空遗留列,下次登录走正常校验 $updateData['password'] = password_hash($password, PASSWORD_DEFAULT); $updateData['legacy_password'] = ''; $updateData['legacy_password_expire_at'] = 0; } if ($userModel->ip !== get_ip()) { $ipTable = json_decode($userModel->ip_table, true); if (!in_array(get_ip(), $ipTable ?? [])) { $ipTable[] = get_ip(); } $updateData['ip'] = get_ip(); $updateData['ip_table'] = json_encode($ipTable); } AdminModel::where('id', $userModel->id)->update($updateData); $userModel = AdminModel::with(['role:id,name,value'])->where('id', $userModel->id)->first(); $this->writeLoginLog( (int) $userModel->id, (string) $userModel->phone, (string) $userModel->nick_name, 0, $legacyHit ? '登录成功(遗留密码已升级)' : '登录成功', $equipment, $browser ); $result = [ 'id' => $userModel->id, 'phone' => $userModel->phone, 'nick_name' => $userModel->nick_name, 'avatar' => $userModel->avatar, 'email' => $userModel->email, 'role_id' => $userModel->role_id, // 迁移过来的账号可能 role_id=0,关联为空时不能直接取属性 'role_name' => $userModel->role->name ?? '', 'role_value' => $userModel->role->value ?? '', 'ip' => $userModel->ip, 'ip_table' => $userModel->ip_table, ]; $token = JWTService::getInstance()->generateToken($result); $result['token'] = $token; // 前端据此提示尽快改密:无盐 sha1 可被彩虹表秒破,升级后也建议换新密码 $result['legacy_password_upgraded'] = $legacyHit; return $result; } /** * 退出登录:删掉 Redis 会话,手里那张 token 立刻作废 * * 注册在免登录组,token 缺失或已过期都不报错——前端登出时本地 token 常常已经清掉了, * 这时报错只会让用户卡在退出流程里。 */ public function logout(): array { $decoded = JWTService::getInstance()->parseExpiringToken($this->refreshTtl()); $userId = (int) ($decoded->data->id ?? 0); if ($userId > 0) { JWTService::getInstance()->revoke($userId); } return ['logout' => true]; } /** * 续签 token * * 前端拦截器在 401 时调这里。签名 + Redis 会话都要通过,只是放宽 exp, * 所以「被登出」和「过期太久」两种情况仍然要求重新登录。 * @throws Exception */ public function refresh(): array { $decoded = JWTService::getInstance()->parseExpiringToken($this->refreshTtl()); $data = isset($decoded->data) ? (array) $decoded->data : []; $userId = (int) ($data['id'] ?? 0); if ($userId <= 0) { UtilsService::getInstance()->notAuth('登录状态已失效,请重新登录'); } // 会话还在才允许续签;顺带用库里的最新角色刷新 payload,改了角色不用等 token 过期 $session = JWTService::getInstance()->getToken()->getUserInfo(); $userModel = AdminModel::with(['role:id,name,value']) ->where('id', $userId) ->where('deleted_at', 0) ->first(); if (empty($userModel) || (int) $userModel->status === 1) { JWTService::getInstance()->revoke($userId); UtilsService::getInstance()->notAuth('账号不可用,请重新登录'); } $payload = array_merge($session, [ 'id' => (int) $userModel->id, 'phone' => $userModel->phone, 'nick_name' => $userModel->nick_name, 'avatar' => $userModel->avatar, 'role_id' => (int) $userModel->role_id, 'role_name' => $userModel->role->name ?? '', 'role_value' => $userModel->role->value ?? '', ]); return ['token' => JWTService::getInstance()->generateToken($payload)]; } /** * 续签宽限期:token 过期后仍可续签的时长,超过就必须重新登录 */ private function refreshTtl(): int { return (int) config('nl.jwt.refresh_ttl', 7 * 24 * 3600); } /** * 比对老系统的无盐 sha1 密码 * * 只在 bcrypt 校验失败时调用。遗留列有失效时间,过期后一律走重置流程, * 因为无盐 sha1 可被彩虹表直接反查,不能无限期留着。 */ private function verifyLegacyPassword(AdminModel $userModel, string $password): bool { $legacy = strtolower(trim((string) ($userModel->legacy_password ?? ''))); if ($legacy === '') { return false; } $expireAt = (int) ($userModel->legacy_password_expire_at ?? 0); if ($expireAt > 0 && $expireAt < get_time()) { return false; } return hash_equals($legacy, sha1($password)); } /** * 注册管理员账号 * * @param string $phone 手机号 * @param string $password 密码 * @param string $email 邮箱 * @param mixed $code 验证码(预留) * @return array * @throws Exception */ public function register($phone, $password, $email, $code): array { // 这是后台管理端,自助注册默认关闭:开着等于任何人都能给自己开一个管理员账号 if (!config('nl.register.enabled', false)) { UtilsService::getInstance()->errorThrow('后台不开放自助注册,请联系管理员创建账号'); } $userModel = AdminModel::where('phone', $phone)->where('deleted_at', 0)->first(); if ($userModel) { UtilsService::getInstance()->errorThrow('账号已被占用!'); } $createModel = AdminModel::create([ 'phone' => $phone, 'email' => $email, 'password' => password_hash($password, PASSWORD_DEFAULT), 'nick_name' => '新用户' . Str::random(), 'avatar' => 'https://pic.rmb.bdstatic.com/bjh/80852bfe7c321988191838517ba64e309354.jpeg@h_1280', // 迁移的老角色统一偏移到 100 起,脚手架预留的 2(默认角色)不受影响,仍可作为默认值 'role_id' => (int) config('nl.register.default_role_id', 2), // open_id 是 NOT NULL 且无默认值,不显式赋值会直接插入失败 'open_id' => 'nl_' . bin2hex(random_bytes(15)), 'ip' => get_ip(), 'ip_table' => json_encode([get_ip()]), // status 列默认值是 1(禁用),不显式写成 0 新账号登录会被状态校验挡下 'status' => 0, 'created_at' => get_time(), ]); if (!$createModel) { UtilsService::getInstance()->errorThrow('注册失败!'); } $userInfo = AdminModel::with(['role:id,name,value'])->where('id', $createModel->id)->first(); $result = [ 'id' => $userInfo->id, 'phone' => $userInfo->phone, 'nick_name' => $userInfo->nick_name, 'avatar' => $userInfo->avatar, 'email' => $userInfo->email ?? '', 'role_name' => $userInfo->role->name ?? '', 'role_value' => $userInfo->role->value ?? '', 'ip' => $userInfo->ip, 'ip_table' => $userInfo->ip_table, ]; $token = JWTService::getInstance()->generateToken($result); $result['token'] = $token; return $result; } /** * 写入登录日志(失败也尽量落库,内部吞异常避免掩盖业务错误) * * @param int $userId 管理员 ID,失败未知用户可为 0 * @param string $phone 登录账号 * @param string $nickName 昵称快照 * @param int $status 0成功 1失败 * @param string $message 结果说明 * @param string $equipment 操作系统 * @param string $browser 浏览器 */ private function writeLoginLog( int $userId, string $phone, string $nickName, int $status, string $message, string $equipment, string $browser ): void { try { LoginLogModel::insert([ 'user_id' => $userId, 'phone' => $phone, 'nick_name' => $nickName, 'ip' => (string) get_ip(), 'equipment' => $equipment, 'browser' => $browser, 'status' => $status, 'message' => $message, 'created_at' => time(), ]); } catch (\Throwable $e) { // 日志失败不影响登录主流程 } } }