1275 lines
39 KiB
PHP
1275 lines
39 KiB
PHP
<?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' => '已登出所有设备',
|
||
]);
|
||
}
|
||
}
|