291 lines
12 KiB
PHP
291 lines
12 KiB
PHP
<?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) {
|
||
// 日志失败不影响登录主流程
|
||
}
|
||
}
|
||
}
|