218 lines
7.2 KiB
PHP
218 lines
7.2 KiB
PHP
<?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,
|
||
];
|
||
}
|
||
}
|