erp-backend/app/Http/Controllers/AuthController.php
2026-04-01 17:07:04 +08:00

1275 lines
39 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\Http\Controllers;
use App\Models\LoginLog;
use App\Models\LoginVerifyCode;
use App\Models\User;
use App\Models\UserDevice;
use App\Services\MailService;
use App\Services\SmsService;
use App\Services\WeChatService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
class AuthController extends Controller
{
protected SmsService $smsService;
protected WeChatService $weChatService;
protected MailService $mailService;
public function __construct(SmsService $smsService, WeChatService $weChatService)
{
$this->smsService = $smsService;
$this->weChatService = $weChatService;
}
/**
* 获取真实IP支持nginx代理
*/
private function getRealIp(Request $request): string
{
$realIp = $request->header('X-Real-IP');
return $realIp ?: $request->ip();
}
/**
* 用户注册
*/
public function register(Request $request)
{
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => [
'required',
'string',
'min:8',
function ($attribute, $value, $fail) {
$errors = User::validatePasswordStrength($value);
if (!empty($errors)) {
$fail($errors[0]);
}
},
],
'password_confirmation' => 'required|same:password',
]);
if ($validator->fails()) {
return response()->json([
'code' => 422,
'message' => '验证失败',
'errors' => $validator->errors(),
], 422);
}
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
$token = $user->createToken('auth_token')->plainTextToken;
$this->logLogin($user, $request, 'password', 'success');
return response()->json([
'code' => 201,
'data' => [
'user' => $this->formatUser($user),
'token' => $token,
],
'message' => '注册成功',
], 201);
}
/**
* 账号密码登录
*/
public function login(Request $request)
{
$validator = Validator::make($request->all(), [
'username' => 'required|string',
'password' => 'required|string',
'captcha' => 'nullable|string',
'device_id' => 'nullable|string',
]);
if ($validator->fails()) {
return response()->json([
'code' => 422,
'message' => '验证失败',
'errors' => $validator->errors(),
], 422);
}
$username = $request->username;
$password = $request->password;
$deviceId = $request->device_id;
// 根据用户名查找用户(支持邮箱或用户名)
$user = User::where('email', $username)
->orWhere('name', $username)
->orWhere('phone', $username)
->first();
// 检查登录失败次数 (针对该用户名的IP)
$ip = $this->getRealIp($request);
$failKey = "login_fail:{$username}:{$ip}";
$failCount = (int) Cache::get($failKey, 0);
// 图形验证码检查 (失败2次后要求)
if ($failCount >= 2) {
$captcha = $request->captcha;
$captchaKey = "captcha:{$deviceId}";
if (!$captcha) {
return response()->json([
'code' => 400,
'message' => '请输入图形验证码',
'data' => [
'require_captcha' => true,
],
], 400);
}
$storedCaptcha = Cache::get($captchaKey);
if (!$storedCaptcha || strtolower($captcha) !== strtolower($storedCaptcha)) {
return response()->json([
'code' => 400,
'message' => '图形验证码错误',
'data' => [
'require_captcha' => true,
],
], 400);
}
}
// 连续失败5次锁定10分钟
if ($failCount >= 5) {
$lockedMinutes = 10;
Cache::put($failKey, $failCount, now()->addMinutes($lockedMinutes));
$this->logLogin($user, $request, 'password', 'failed', '连续登录失败,账号已锁定');
return response()->json([
'code' => 429,
'message' => "连续登录失败次数过多,账号已锁定{$lockedMinutes}分钟",
], 429);
}
// 验证账号
if (!$user || !Hash::check($password, $user->password)) {
Cache::put($failKey, $failCount + 1, now()->addHour());
$this->logLogin($user, $request, 'password', 'failed', '用户名或密码错误');
return response()->json([
'code' => 401,
'message' => '用户名或密码错误',
'data' => [
'fail_count' => $failCount + 1,
'require_captcha' => $failCount + 1 >= 2,
],
], 401);
}
// 检查用户状态
if ($user->isLocked()) {
$remaining = $user->getLockedRemainingSeconds();
$minutes = ceil($remaining / 60);
$this->logLogin($user, $request, 'password', 'failed', '账号已被锁定');
return response()->json([
'code' => 403,
'message' => "账号已被锁定,请{$minutes}分钟后再试",
'data' => [
'locked_until' => $user->locked_until,
'locked_remaining' => $remaining,
],
], 403);
}
// 登录成功
Cache::forget($failKey);
$user->recordLogin($ip);
// 检查是否为授信设备(异地登录检测)
$deviceHash = UserDevice::generateHash($request->userAgent() ?? '', $ip);
$trustedDevice = UserDevice::where('user_id', $user->id)
->where('device_hash', $deviceHash)
->where('is_trusted', true)
->first();
// 如果设备未授信或不存在,要求配对码验证
if (!$trustedDevice) {
// 记录新设备登录
$this->logLogin($user, $request, 'password', 'pending', '新设备登录,待配对码验证');
// 自动发送配对码邮件
$expireMinutes = 10;
$code = \App\Models\LoginVerifyCode::generateCode();
$deviceHashForCode = UserDevice::generateHash($request->userAgent() ?? '', $ip);
// 保存配对码
$verifyCode = \App\Models\LoginVerifyCode::create([
'user_id' => $user->id,
'code' => $code,
'type' => 'pair',
'device_hash' => $deviceHashForCode,
'ip' => $ip,
'user_agent' => $request->userAgent(),
'expires_at' => now()->addMinutes($expireMinutes),
]);
// 发送邮件
$mailService = new MailService();
$sent = $mailService->sendPairCode($user->email, $code, $expireMinutes);
if (!$sent) {
\Log::warning("自动发送配对码邮件失败: {$code}");
}
return response()->json([
'code' => 200,
'data' => [
'require_pair_code' => true,
'device_hash' => $deviceHash,
'email' => $user->email,
'pair_code' => $code, // 开发环境返回,方便测试
'message' => '检测到新设备登录,配对码已发送至绑定邮箱',
],
'message' => '检测到新设备登录,配对码已发送至绑定邮箱',
]);
}
// 授信设备,直接登录成功
$token = $user->createToken('auth_token')->plainTextToken;
// 更新设备最后登录时间
$trustedDevice->update(['last_login_at' => now()]);
$this->logLogin($user, $request, 'password', 'success');
return response()->json([
'code' => 200,
'data' => [
'user' => $this->formatUser($user),
'token' => $token,
],
'message' => '登录成功',
]);
}
/**
* 短信验证码登录 - 发送验证码
*/
public function sendSmsCode(Request $request)
{
$validator = Validator::make($request->all(), [
'phone' => ['required', 'string', 'regex:/^1[3-9]\d{9}$/'],
'captcha' => 'nullable|string',
'device_id' => 'nullable|string',
]);
if ($validator->fails()) {
return response()->json([
'code' => 422,
'message' => '验证失败',
'errors' => $validator->errors(),
], 422);
}
$phone = $request->phone;
$deviceId = $request->device_id;
// 检查是否需要图形验证码 (短信连续失败2次后)
$smsFailKey = "sms_fail:{$phone}";
$smsFailCount = (int) Cache::get($smsFailKey, 0);
if ($smsFailCount >= 2) {
$captcha = $request->captcha;
$captchaKey = "captcha:{$deviceId}";
if (!$captcha) {
return response()->json([
'code' => 400,
'message' => '请输入图形验证码',
'data' => ['require_captcha' => true],
], 400);
}
$storedCaptcha = Cache::get($captchaKey);
if (!$storedCaptcha || strtolower($captcha) !== strtolower($storedCaptcha)) {
return response()->json([
'code' => 400,
'message' => '图形验证码错误',
'data' => ['require_captcha' => true],
], 400);
}
}
// 发送短信
$result = $this->smsService->sendCode($phone, 'login');
if (!$result['success']) {
return response()->json([
'code' => 429,
'message' => $result['message'],
], 429);
}
return response()->json([
'code' => 200,
'message' => $result['message'],
]);
}
/**
* 短信验证码登录 - 验证登录
*/
public function smsLogin(Request $request)
{
$validator = Validator::make($request->all(), [
'phone' => ['required', 'string', 'regex:/^1[3-9]\d{9}$/'],
'code' => 'required|string|size:6',
]);
if ($validator->fails()) {
return response()->json([
'code' => 422,
'message' => '验证失败',
'errors' => $validator->errors(),
], 422);
}
$phone = $request->phone;
$code = $request->code;
// 验证短信验证码
if (!$this->smsService->verifyCode($phone, $code, 'login')) {
$failKey = "sms_fail:{$phone}";
$failCount = (int) Cache::get($failKey, 0) + 1;
Cache::put($failKey, $failCount, now()->addHour());
$this->logLogin(null, $request, 'sms', 'failed', '验证码错误');
return response()->json([
'code' => 401,
'message' => '验证码错误',
'data' => ['fail_count' => $failCount],
], 401);
}
// 查找或创建用户
$user = User::findByPhone($phone);
if (!$user) {
// 新用户自动注册(可选,根据业务需求)
$user = User::create([
'name' => '用户_' . substr($phone, -4),
'email' => "phone_{$phone}@erp.local",
'password' => Hash::make(bin2hex(random_bytes(8))),
'phone' => $phone,
]);
}
$user->recordLogin($this->getRealIp($request));
$token = $user->createToken('sms_auth')->plainTextToken;
$this->logLogin($user, $request, 'sms', 'success');
return response()->json([
'code' => 200,
'data' => [
'user' => $this->formatUser($user),
'token' => $token,
],
'message' => '登录成功',
]);
}
/**
* 获取微信登录二维码
*/
public function wechatQr(Request $request)
{
$result = $this->weChatService->getLoginQr();
if (!$result['success']) {
return response()->json([
'code' => 500,
'message' => $result['message'],
], 500);
}
return response()->json([
'code' => 200,
'data' => $result['data'],
'message' => '获取成功',
]);
}
/**
* 检查微信扫码状态
*/
public function wechatCheck(Request $request)
{
$validator = Validator::make($request->all(), [
'scene_str' => 'required|string',
]);
if ($validator->fails()) {
return response()->json([
'code' => 422,
'message' => '验证失败',
'errors' => $validator->errors(),
], 422);
}
$result = $this->weChatService->checkScanStatus($request->scene_str);
return response()->json([
'code' => 200,
'data' => $result['data'],
'message' => 'success',
]);
}
/**
* 微信扫码登录 - 模拟扫码登录(开发测试用)
*/
public function wechatMockScan(Request $request)
{
$validator = Validator::make($request->all(), [
'scene_str' => 'required|string',
'openid' => 'nullable|string',
]);
if ($validator->fails()) {
return response()->json([
'code' => 422,
'message' => '验证失败',
'errors' => $validator->errors(),
], 422);
}
$this->weChatService->mockScan($request->scene_str, $request->openid);
return response()->json([
'code' => 200,
'message' => '模拟扫码成功',
]);
}
/**
* 微信回调处理
*/
public function wechatCallback(Request $request)
{
$code = $request->get('code');
$state = $request->get('state');
if (!$code) {
return response()->json([
'code' => 400,
'message' => '缺少授权码',
], 400);
}
$result = $this->weChatService->handleCallback($code);
if (!$result['success']) {
return response()->json([
'code' => 500,
'message' => $result['message'],
], 500);
}
$this->logLogin($result['data']['user'], $request, 'wechat', 'success');
return response()->json([
'code' => 200,
'data' => [
'user' => $this->formatUser($result['data']['user']),
'token' => $result['data']['token'],
],
'message' => '登录成功',
]);
}
/**
* 获取当前用户信息
*/
public function me(Request $request)
{
return response()->json([
'code' => 200,
'data' => $this->formatUser($request->user()),
'message' => 'success',
]);
}
/**
* 登出
*/
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json([
'code' => 200,
'message' => '登出成功',
]);
}
/**
* 刷新 Token
*/
public function refresh(Request $request)
{
$user = $request->user();
$user->tokens()->delete();
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'code' => 200,
'data' => [
'token' => $token,
'user' => $this->formatUser($user),
],
'message' => '刷新成功',
]);
}
/**
* 更新个人资料
*/
public function updateProfile(Request $request)
{
$user = $request->user();
$validator = Validator::make($request->all(), [
'name' => 'sometimes|string|max:255',
'email' => ['sometimes', 'email', 'max:255', 'unique:users,email,' . $user->id],
'phone' => ['sometimes', 'string', 'regex:/^1[3-9]\d{9}$/', 'unique:users,phone,' . $user->id],
'avatar' => 'sometimes|string|url|max:500',
]);
if ($validator->fails()) {
return response()->json([
'code' => 422,
'message' => '验证失败',
'errors' => $validator->errors(),
], 422);
}
// 如果要修改邮箱
if ($request->has('email') && $request->email !== $user->email) {
$newEmail = $request->email;
// 发送邮箱验证邮件
$code = \App\Models\LoginVerifyCode::generateCode();
$deviceHash = UserDevice::generateHash($request->userAgent() ?? '', $this->getRealIp($request) ?? '');
\App\Models\LoginVerifyCode::create([
'user_id' => $user->id,
'code' => $code,
'type' => 'email_change',
'device_hash' => $deviceHash,
'ip' => $this->getRealIp($request),
'user_agent' => $request->userAgent(),
'expires_at' => now()->addMinutes(30),
'extra' => json_encode(['new_email' => $newEmail]),
]);
// 发送验证邮件到新邮箱
$mailService = new MailService();
$mailService->sendEmailChangeCode($newEmail, $code, $user->name);
return response()->json([
'code' => 200,
'data' => [
'require_email_verify' => true,
'message' => '验证码已发送到新邮箱,请查收',
],
'message' => '验证码已发送到新邮箱,请查收',
]);
}
$user->update($request->only(['name', 'phone', 'avatar']));
return response()->json([
'code' => 200,
'data' => $this->formatUser($user),
'message' => '更新成功',
]);
}
/**
* 发送邮箱修改验证码
*/
public function sendEmailChangeCode(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|email',
]);
if ($validator->fails()) {
return response()->json(['code' => 422, 'message' => '验证失败', 'errors' => $validator->errors()], 422);
}
$newEmail = $request->email;
$user = $request->user();
// 检查邮箱是否已被其他用户使用
$exists = User::where('email', $newEmail)->where('id', '!=', $user->id)->exists();
if ($exists) {
return response()->json(['code' => 400, 'message' => '该邮箱已被其他用户使用'], 400);
}
// 生成6位数字验证码
$code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
// 保存验证码
LoginVerifyCode::create([
'user_id' => $user->id,
'code' => $code,
'type' => 'email_change',
'extra' => json_encode(['new_email' => $newEmail]),
'ip' => $this->getRealIp($request),
'user_agent' => $request->userAgent(),
'expires_at' => now()->addMinutes(10),
]);
// 发送邮件
$sent = $this->mailService->sendEmailChangeCode($newEmail, $code, $user->name);
if (!$sent) {
return response()->json(['code' => 500, 'message' => '邮件发送失败,请检查邮箱地址是否正确'], 500);
}
return response()->json([
'code' => 200,
'message' => '验证码已发送到新邮箱',
]);
}
/**
* 验证邮箱修改
*/
public function verifyEmailChange(Request $request)
{
$validator = Validator::make($request->all(), [
'code' => 'required|string|size:6',
]);
if ($validator->fails()) {
return response()->json(['code' => 422, 'message' => '验证失败', 'errors' => $validator->errors()], 422);
}
$user = $request->user();
$verifyCode = \App\Models\LoginVerifyCode::findValidCode($user, $request->code, 'email_change');
if (!$verifyCode) {
return response()->json(['code' => 400, 'message' => '验证码无效或已过期'], 400);
}
// 解析新邮箱
$extra = json_decode($verifyCode->extra, true);
$newEmail = $extra['new_email'] ?? null;
if (!$newEmail) {
return response()->json(['code' => 400, 'message' => '无效的验证数据'], 400);
}
// 标记已使用
$verifyCode->markAsUsed();
// 更新邮箱
$user->email = $newEmail;
$user->save();
return response()->json([
'code' => 200,
'data' => $this->formatUser($user),
'message' => '邮箱修改成功',
]);
}
/**
* 修改密码
*/
public function changePassword(Request $request)
{
$validator = Validator::make($request->all(), [
'current_password' => 'required|string',
'new_password' => [
'required',
'string',
'min:8',
function ($attribute, $value, $fail) {
$errors = User::validatePasswordStrength($value);
if (!empty($errors)) {
$fail($errors[0]);
}
},
],
'new_password_confirmation' => 'required|same:new_password',
]);
if ($validator->fails()) {
return response()->json([
'code' => 422,
'message' => '验证失败',
'errors' => $validator->errors(),
], 422);
}
$user = $request->user();
if (!Hash::check($request->current_password, $user->password)) {
return response()->json([
'code' => 400,
'message' => '当前密码错误',
], 400);
}
$user->password = Hash::make($request->new_password);
$user->save();
return response()->json([
'code' => 200,
'message' => '密码修改成功',
]);
}
/**
* 图形验证码生成
*/
public function captcha(Request $request)
{
$deviceId = $request->get('device_id', $this->getRealIp($request));
// 生成4位随机字符
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
$code = '';
for ($i = 0; $i < 4; $i++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
// 存储验证码
Cache::put("captcha:{$deviceId}", $code, now()->addMinutes(5));
// 生成简单验证码图片 (使用 SVG)
$svg = $this->generateCaptchaSvg($code);
return response($svg, 200, [
'Content-Type' => 'image/svg+xml',
'Cache-Control' => 'no-cache, no-store',
]);
}
/**
* 生成简单 SVG 验证码
*/
private function generateCaptchaSvg(string $code): string
{
$width = 120;
$height = 40;
// 随机颜色
$bgColor = sprintf('#%06X', random_int(0, 0xF0F0F0));
$textColor = sprintf('#%06X', random_int(0, 0x333333));
$svg = <<<SVG
<svg xmlns="http://www.w3.org/2000/svg" width="{$width}" height="{$height}" viewBox="0 0 {$width} {$height}">
<rect width="{$width}" height="{$height}" fill="{$bgColor}" rx="8"/>
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle"
font-family="Arial, sans-serif" font-size="24" font-weight="bold"
fill="{$textColor}" transform="rotate(-5, 60, 20)">{$code[0]}</text>
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle"
font-family="Arial, sans-serif" font-size="24" font-weight="bold"
fill="{$textColor}" transform="rotate(3, 60, 20)">{$code[1]}</text>
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle"
font-family="Arial, sans-serif" font-size="24" font-weight="bold"
fill="{$textColor}" transform="rotate(-2, 60, 20)">{$code[2]}</text>
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle"
font-family="Arial, sans-serif" font-size="24" font-weight="bold"
fill="{$textColor}" transform="rotate(5, 60, 20)">{$code[3]}</text>
<!-- 干扰线 -->
<line x1="0" y1="20" x2="{$width}" y2="25" stroke="#ccc" stroke-width="1"/>
<line x1="0" y1="35" x2="{$width}" y2="30" stroke="#ccc" stroke-width="1"/>
</svg>
SVG;
return $svg;
}
/**
* 绑定微信到当前账号
*/
public function bindWechat(Request $request)
{
$validator = Validator::make($request->all(), [
'code' => 'required|string',
]);
if ($validator->fails()) {
return response()->json([
'code' => 422,
'message' => '验证失败',
'errors' => $validator->errors(),
], 422);
}
$result = $this->weChatService->handleCallback($request->code);
if (!$result['success']) {
return response()->json([
'code' => 500,
'message' => $result['message'],
], 500);
}
$user = $request->user();
$bindResult = $this->weChatService->bindToUser(
$user,
$result['data']['openid'],
$result['data']['unionid'] ?? null
);
if (!$bindResult) {
return response()->json([
'code' => 400,
'message' => '该微信已被其他账号绑定',
], 400);
}
return response()->json([
'code' => 200,
'message' => '绑定成功',
]);
}
/**
* 解绑微信
*/
public function unbindWechat(Request $request)
{
$user = $request->user();
if (!$user->wechat_openid) {
return response()->json([
'code' => 400,
'message' => '未绑定微信',
], 400);
}
$this->weChatService->unbind($user);
return response()->json([
'code' => 200,
'message' => '解绑成功',
]);
}
/**
* 忘记密码 - 发送验证码
*/
public function sendResetCode(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|email|exists:users,email',
]);
if ($validator->fails()) {
return response()->json([
'code' => 422,
'message' => '验证失败',
'errors' => $validator->errors(),
], 422);
}
// 使用短信服务发送验证码到用户绑定的手机号
$user = User::where('email', $request->email)->first();
if (!$user || !$user->phone) {
return response()->json([
'code' => 400,
'message' => '该账号未绑定手机号,请联系管理员',
], 400);
}
$result = $this->smsService->sendCode($user->phone, 'reset_password');
if (!$result['success']) {
return response()->json([
'code' => 429,
'message' => $result['message'],
], 429);
}
return response()->json([
'code' => 200,
'message' => '验证码已发送',
]);
}
/**
* 重置密码
*/
public function resetPassword(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|email|exists:users,email',
'code' => 'required|string|size:6',
'new_password' => [
'required',
'string',
'min:8',
function ($attribute, $value, $fail) {
$errors = User::validatePasswordStrength($value);
if (!empty($errors)) {
$fail($errors[0]);
}
},
],
]);
if ($validator->fails()) {
return response()->json([
'code' => 422,
'message' => '验证失败',
'errors' => $validator->errors(),
], 422);
}
$user = User::where('email', $request->email)->first();
if (!$this->smsService->verifyCode($user->phone, $request->code, 'reset_password')) {
return response()->json([
'code' => 401,
'message' => '验证码错误或已过期',
], 401);
}
$user->password = Hash::make($request->new_password);
$user->save();
return response()->json([
'code' => 200,
'message' => '密码重置成功',
]);
}
/**
* 获取登录日志
*/
public function loginLogs(Request $request)
{
$query = LoginLog::where('user_id', $request->user()->id)
->orderBy('created_at', 'desc');
$total = $query->count();
$logs = $query->limit(20)->get();
return response()->json([
'code' => 200,
'data' => [
'list' => $logs,
'total' => $total,
],
'message' => 'success',
]);
}
/**
* 获取授信设备列表
*/
public function getTrustedDevices(Request $request)
{
$devices = UserDevice::where('user_id', $request->user()->id)
->where('is_trusted', true)
->orderBy('last_login_at', 'desc')
->get();
return response()->json([
'code' => 200,
'data' => $devices,
'message' => 'success',
]);
}
/**
* 取消设备授信
*/
public function revokeDevice(Request $request, $id)
{
$device = UserDevice::where('user_id', $request->user()->id)
->where('id', $id)
->first();
if (!$device) {
return response()->json([
'code' => 404,
'message' => '设备不存在',
], 404);
}
$device->is_trusted = false;
$device->save();
return response()->json([
'code' => 200,
'message' => '已取消授信',
]);
}
/**
* 批准设备申请
*/
public function approveDevice(Request $request, $id)
{
$device = UserDevice::where('user_id', $request->user()->id)
->where('id', $id)
->first();
if (!$device) {
return response()->json([
'code' => 404,
'message' => '设备不存在',
], 404);
}
$device->is_trusted = true;
$device->trusted_at = now();
$device->save();
return response()->json([
'code' => 200,
'message' => '已批准',
]);
}
/**
* 记录登录日志
*/
private function logLogin(?User $user, Request $request, string $type, string $status, string $reason = null): void
{
// 获取真实IP优先从nginx传递的X-Real-IP获取
$realIp = $this->getRealIp($request);
try {
LoginLog::create([
'user_id' => $user?->id,
'username' => $request->username ?? $request->phone ?? ($user ? $user->email : 'unknown'),
'login_type' => $type,
'status' => $status,
'ip' => $realIp,
'user_agent' => $request->userAgent(),
'device_id' => $request->device_id,
'failure_reason' => $reason,
]);
} catch (\Exception $e) {
Log::error('记录登录日志失败: ' . $e->getMessage());
}
}
/**
* 格式化用户数据
*/
private function formatUser(User $user): array
{
$roles = $user->roles;
return [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'phone' => $user->phone,
'avatar' => $user->avatar,
'status' => $user->status,
'is_test_user' => $user->isTestUser(),
'roleCode' => $roles->first()?->slug,
'roleName' => $roles->first()?->name,
'roles' => $roles->map(fn($r) => ['id' => $r->id, 'name' => $r->name, 'slug' => $r->slug])->toArray(),
'warehouseIds' => $user->warehouse_ids ?? [],
'created_at' => $user->created_at?->toIso8601String(),
'last_login_at' => $user->last_login_at?->toIso8601String(),
];
}
/**
* 发送异地登录配对码
*/
public function sendPairCode(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|email',
'expireMinutes' => 'nullable|integer|min:5|max:60',
]);
if ($validator->fails()) {
return response()->json(['code' => 422, 'message' => '验证失败', 'errors' => $validator->errors()], 422);
}
$user = User::where('email', $request->email)->first();
if (!$user) {
return response()->json(['code' => 404, 'message' => '用户不存在'], 404);
}
// 频率限制60秒内只能发送一次
$rateKey = "pair_code_rate:{$user->id}";
if (Cache::has($rateKey)) {
$remaining = Cache::get($rateKey . '_ttl', 0);
return response()->json([
'code' => 429,
'message' => "发送太频繁,请{$remaining}秒后再试",
], 429);
}
$expireMinutes = $request->input('expireMinutes', 10);
$code = \App\Models\LoginVerifyCode::generateCode();
$deviceHash = UserDevice::generateHash($request->userAgent() ?? '', $this->getRealIp($request) ?? '');
// 保存配对码
$verifyCode = \App\Models\LoginVerifyCode::create([
'user_id' => $user->id,
'code' => $code,
'type' => 'pair',
'device_hash' => $deviceHash,
'ip' => $this->getRealIp($request),
'user_agent' => $request->userAgent(),
'expires_at' => now()->addMinutes($expireMinutes),
]);
// 设置频率限制
Cache::put($rateKey, 1, now()->addSeconds(60));
Cache::put($rateKey . '_ttl', 60, now()->addSeconds(60));
// 发送邮件
$mailService = new MailService();
$sent = $mailService->sendPairCode($user->email, $code, $expireMinutes);
if (!$sent) {
\Log::warning("配对码邮件发送失败,但已生成配对码: {$code}");
}
return response()->json([
'code' => 200,
'data' => [
'code' => $code, // 开发环境返回,方便测试
'expiresAt' => $verifyCode->expires_at->toIso8601String(),
'email' => $user->email,
],
'message' => '配对码已发送至绑定邮箱',
]);
}
/**
* 验证配对码
*/
public function verifyPairCode(Request $request)
{
$validator = Validator::make($request->all(), [
'code' => 'required|string|size:8', // 改为8位
'email' => 'required|email',
]);
if ($validator->fails()) {
return response()->json(['code' => 422, 'message' => '验证失败', 'errors' => $validator->errors()], 422);
}
$user = User::where('email', $request->email)->first();
if (!$user) {
return response()->json(['code' => 404, 'message' => '用户不存在'], 404);
}
$verifyCode = \App\Models\LoginVerifyCode::findValidCode($user, $request->code, 'pair');
if (!$verifyCode) {
// 配对码无效,可能已被锁定或过期
return response()->json([
'code' => 400,
'message' => '配对码无效或已过期',
], 400);
}
// 检查失败次数
if ($verifyCode->fail_count >= 5) {
// 失败次数过多,标记为已使用(锁定)
$verifyCode->markAsUsed();
return response()->json([
'code' => 429,
'message' => '验证失败次数过多,请重新获取配对码',
], 429);
}
// 验证失败,增加失败计数
$verifyCode->increment('fail_count');
if ($verifyCode->fail_count >= 5) {
$verifyCode->markAsUsed();
return response()->json([
'code' => 429,
'message' => '验证失败次数过多,请重新获取配对码',
], 429);
}
// 标记已使用
$verifyCode->markAsUsed();
// 记录授信设备
$deviceInfo = UserDevice::parseDeviceInfo($request->userAgent() ?? '');
$deviceHash = UserDevice::generateHash($request->userAgent() ?? '', $this->getRealIp($request) ?? '');
$device = UserDevice::findByHash($user->id, $deviceHash);
if ($device) {
$device->trust();
$device->recordLogin();
} else {
UserDevice::create([
'user_id' => $user->id,
'device_hash' => $deviceHash,
'device_name' => ($deviceInfo['browser'] ?? 'Unknown') . ' on ' . ($deviceInfo['os'] ?? 'Unknown'),
'device_type' => $deviceInfo['device_type'] ?? 'desktop',
'os' => $deviceInfo['os'] ?? 'Unknown',
'browser' => $deviceInfo['browser'] ?? 'Unknown',
'ip' => $this->getRealIp($request),
'is_trusted' => true,
'trusted_at' => now(),
'first_login_at' => now(),
'last_login_at' => now(),
]);
}
// 创建token
$token = $user->createToken('auth_token')->plainTextToken;
// 记录登录日志
$this->logLogin($user, $request, 'pair', 'success');
return response()->json([
'code' => 200,
'data' => [
'user' => $this->formatUser($user),
'token' => $token,
],
'message' => '验证成功,已登录',
]);
}
/**
* 登出所有设备
*/
public function logoutAllDevices(Request $request)
{
$user = $request->user();
// 删除所有 token
$user->tokens()->delete();
// 取消所有设备授信
UserDevice::where('user_id', $user->id)->update(['is_trusted' => false, 'trusted_at' => null]);
return response()->json([
'code' => 200,
'message' => '已登出所有设备',
]);
}
}