Files
lgp-admin-plus-api/app/Service/LoginService.php
LQ bcf54c2727 初始化
缺陷:主题配色需要优化整体的同风格
2026-08-14 23:21:21 +08:00

291 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Service;
use App\BaseApp\BaseNotAuthService;
use App\Models\AdminModel;
use App\Models\LoginLogModel;
use App\Service\common\JWTService;
use App\Service\common\UserAgentService;
use App\Service\common\UtilsService;
use Exception;
use Illuminate\Support\Str;
/**
* 登录 / 注册服务:成功与失败均写登录日志,成功时更新 last_login_time
*/
class LoginService extends BaseNotAuthService
{
/**
* 账号密码登录
* 失败时先写日志再抛错,保证审计完整;成功始终更新 last_login_time
*
* @param string $phone 手机号/账号
* @param string $password 明文密码
* @return array 用户信息 + token
* @throws Exception
*/
public function login($phone, $password): array
{
$ua = UserAgentService::getInstance();
$equipment = $ua->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) {
// 日志失败不影响登录主流程
}
}
}