QuickQuiz/app/Http/Controllers/Api/AuthController.php

218 lines
7.2 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
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\InviteCode;
use App\Models\OperationLog;
use App\Models\User;
use App\Support\ApiResponse;
use hg\apidoc\annotation as Apidoc;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Tymon\JWTAuth\Facades\JWTAuth;
#[Apidoc\Group('认证')]
#[Apidoc\Title('认证接口')]
final class AuthController extends Controller
{
#[Apidoc\Title('邀请码注册')]
#[Apidoc\Url('/api/auth/register')]
#[Apidoc\Method('POST')]
public function register(Request $request): JsonResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:50'],
'email' => ['required', 'email', 'max:120', 'unique:users,email'],
'password' => ['required', 'string', 'min:6', 'confirmed'],
'invite_code' => ['required', 'string'],
]);
$invite = InviteCode::query()->where('code', $data['invite_code'])->lockForUpdate()->first();
if (! $invite || ! $invite->available()) {
throw ValidationException::withMessages(['invite_code' => '邀请码无效']);
}
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'role' => $invite->role,
'is_active' => true,
'password' => Hash::make($data['password']),
]);
$invite->increment('used_count');
return ApiResponse::success($this->tokenPayload($user), '注册成功');
}
#[Apidoc\Title('验证码')]
#[Apidoc\Url('/api/auth/captcha')]
#[Apidoc\Method('GET')]
public function captcha(Request $request): JsonResponse
{
$code = (string) random_int(1000, 9999);
$request->session()->put('captcha', $code);
return ApiResponse::success([
'captcha' => $code,
'expires_in' => 300,
], '验证码已生成');
}
#[Apidoc\Title('登录')]
#[Apidoc\Url('/api/auth/login')]
#[Apidoc\Method('POST')]
public function login(Request $request): JsonResponse
{
$data = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'captcha' => ['nullable', 'string'],
]);
$key = 'login:'.$request->ip().':'.$data['email'];
$user = User::query()->where('email', $data['email'])->first();
if (RateLimiter::tooManyAttempts($key, 5) || ($user?->failed_login_count ?? 0) >= 5) {
$captcha = (string) ($data['captcha'] ?? '');
$expectedCaptcha = (string) session('captcha', '');
if ($captcha === '' || $expectedCaptcha === '' || $captcha !== $expectedCaptcha) {
return ApiResponse::error('请输入验证码', 429, 429, ['captcha_required' => true]);
}
}
if (! $user || ! Hash::check($data['password'], $user->password)) {
RateLimiter::hit($key, 300);
$user?->update([
'failed_login_count' => $user->failed_login_count + 1,
'last_failed_login_at' => now(),
]);
return ApiResponse::error('账号或密码错误', 422, 422, [
'captcha_required' => RateLimiter::attempts($key) >= 5,
]);
}
if (! $user->is_active) {
return ApiResponse::error('账号已被禁用', 403, 403);
}
RateLimiter::clear($key);
$user->update(['failed_login_count' => 0, 'last_login_at' => now()]);
OperationLog::create([
'user_id' => $user->id,
'action' => 'auth.login',
'ip' => $request->ip(),
]);
return ApiResponse::success($this->tokenPayload($user), '登录成功');
}
#[Apidoc\Title('刷新Token')]
#[Apidoc\Url('/api/auth/refresh')]
#[Apidoc\Method('POST')]
public function refresh(): JsonResponse
{
return ApiResponse::success([
'token' => JWTAuth::refresh(JWTAuth::getToken()),
'token_type' => 'bearer',
'expires_in' => auth('api')->factory()->getTTL() * 60,
]);
}
#[Apidoc\Title('当前用户')]
#[Apidoc\Url('/api/auth/me')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['jwt.auth'])]
public function me(Request $request): JsonResponse
{
return ApiResponse::success($request->user()->loadMissing('permissions'));
}
#[Apidoc\Title('退出登录')]
#[Apidoc\Url('/api/auth/logout')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['jwt.auth'])]
public function logout(): JsonResponse
{
JWTAuth::invalidate(JWTAuth::getToken());
return ApiResponse::success(null, '已退出');
}
#[Apidoc\Title('发送找回密码邮件')]
#[Apidoc\Url('/api/auth/forgot-password')]
#[Apidoc\Method('POST')]
public function forgotPassword(Request $request): JsonResponse
{
$data = $request->validate(['email' => ['required', 'email']]);
$user = User::query()->where('email', $data['email'])->first();
if (! $user) {
return ApiResponse::success(null, '如果邮箱存在,系统会发送重置邮件');
}
$token = Password::broker()->createToken($user);
if (config('mail.default') !== 'smtp' || ! config('mail.mailers.smtp.host')) {
OperationLog::create([
'user_id' => $user->id,
'action' => 'auth.password_reset_token_created',
'payload' => ['token' => $token],
]);
return ApiResponse::success(['token' => $token], '邮件未配置,已返回重置 token');
}
Mail::raw("QuickQuiz 密码重置 Token{$token}", fn ($message) => $message->to($user->email)->subject('QuickQuiz 密码重置'));
return ApiResponse::success(null, '重置邮件已发送');
}
#[Apidoc\Title('重置密码')]
#[Apidoc\Url('/api/auth/reset-password')]
#[Apidoc\Method('POST')]
public function resetPassword(Request $request): JsonResponse
{
$data = $request->validate([
'email' => ['required', 'email'],
'token' => ['required', 'string'],
'password' => ['required', 'string', 'min:6', 'confirmed'],
]);
$status = Password::broker()->reset($data, function (User $user, string $password): void {
$user->forceFill([
'password' => Hash::make($password),
'remember_token' => Str::random(60),
])->save();
});
if ($status !== Password::PASSWORD_RESET) {
return ApiResponse::error('重置失败', 422, 422, ['status' => $status]);
}
return ApiResponse::success(null, '密码已重置');
}
private function tokenPayload(User $user): array
{
return [
'token' => JWTAuth::fromUser($user),
'token_type' => 'bearer',
'expires_in' => auth('api')->factory()->getTTL() * 60,
'user' => $user,
];
}
}