feat: implement short-lived grant system for OTP verification and enhance rate limiting across authentication endpoints
This commit is contained in:
@@ -31,6 +31,9 @@ class AuthController extends BaseController
|
||||
private readonly OtpService $otpService,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly RateLimiterFactory $sendCodeLimiter,
|
||||
private readonly RateLimiterFactory $verifyCodeLimiter,
|
||||
private readonly RateLimiterFactory $tokenIssueLimiter,
|
||||
private readonly RateLimiterFactory $passwordResetLimiter,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
@@ -185,6 +188,10 @@ class AuthController extends BaseController
|
||||
#[Route('/api/v1/user/verify-code', methods: ['POST'])]
|
||||
public function verifyCode(Request $request): JsonResponse
|
||||
{
|
||||
if ($resp = $this->enforceLimit($this->verifyCodeLimiter, $request)) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
$code = trim($data['code'] ?? '');
|
||||
@@ -197,7 +204,8 @@ class AuthController extends BaseController
|
||||
$isNewUser = $this->userRepo->findByMobile($otpData['mobile']) === null;
|
||||
|
||||
return $this->success([
|
||||
'message' => 'کد با موفقیت تایید شد.',
|
||||
'message' => 'کد با موفقیت تایید شد.',
|
||||
'grant' => $otpData['grant'],
|
||||
'is_new_user' => $isNewUser,
|
||||
]);
|
||||
}
|
||||
@@ -208,9 +216,9 @@ class AuthController extends BaseController
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['uuid'],
|
||||
required: ['grant'],
|
||||
properties: [
|
||||
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
|
||||
new OA\Property(property: 'grant', type: 'string', description: 'grant یکبارمصرف از verify-code'),
|
||||
new OA\Property(property: 'real_name', type: 'string', example: 'علی محمدی'),
|
||||
]
|
||||
)
|
||||
@@ -260,15 +268,14 @@ class AuthController extends BaseController
|
||||
public function register(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
$grant = trim($data['grant'] ?? '');
|
||||
$realName = trim($data['real_name'] ?? '');
|
||||
|
||||
if (empty($uuid)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'uuid الزامی است', 422);
|
||||
if ($grant === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'grant الزامی است', 422);
|
||||
}
|
||||
|
||||
$otpData = $this->otpService->getVerifiedOtpData($uuid);
|
||||
$mobile = $otpData['mobile'];
|
||||
$mobile = $this->otpService->consumeGrant($grant);
|
||||
|
||||
$user = $this->userRepo->findByMobile($mobile) ?? new User($mobile);
|
||||
if ($realName !== '') {
|
||||
@@ -276,22 +283,21 @@ class AuthController extends BaseController
|
||||
}
|
||||
|
||||
$this->userRepo->save($user);
|
||||
$this->otpService->deleteOtp($uuid);
|
||||
|
||||
return $this->success(['message' => 'ثبتنام با موفقیت انجام شد.', 'uuid' => $user->getUuid()], 201);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/oauth/token',
|
||||
summary: 'مرحله ۳ — دریافت JWT با uuid تأییدشده',
|
||||
description: 'uuid را از مرحله ۱ (`/api/v1/user/send-code`) وارد کنید — **بعد از** اینکه در مرحله ۲ (`/api/v1/user/verify-code`) تأیید شد. access_token را در header درخواستهای بعدی استفاده کنید: `Authorization: Bearer <access_token>`',
|
||||
summary: 'مرحله ۳ — دریافت JWT با grant تأییدشده',
|
||||
description: 'فیلد `grant` را از پاسخ مرحله ۲ (`/api/v1/user/verify-code`) وارد کنید. این grant یکبارمصرف و کوتاهعمر (۱۲۰ ثانیه) است. access_token را در header درخواستهای بعدی استفاده کنید: `Authorization: Bearer <access_token>`',
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['grant_type', 'uuid'],
|
||||
required: ['grant_type', 'grant'],
|
||||
properties: [
|
||||
new OA\Property(property: 'grant_type', type: 'string', enum: ['mobile'], example: 'mobile'),
|
||||
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
|
||||
new OA\Property(property: 'grant', type: 'string', description: 'grant یکبارمصرف از verify-code'),
|
||||
]
|
||||
)
|
||||
),
|
||||
@@ -331,20 +337,25 @@ class AuthController extends BaseController
|
||||
#[Route('/oauth/token', methods: ['POST'])]
|
||||
public function issueToken(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$grant = $data['grant_type'] ?? '';
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
|
||||
if ($grant !== 'mobile') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'grant_type نامعتبر است', 400);
|
||||
if ($resp = $this->enforceLimit($this->tokenIssueLimiter, $request)) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$otpData = $this->otpService->getVerifiedOtpData($uuid);
|
||||
$mobile = $otpData['mobile'];
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$grantType = $data['grant_type'] ?? '';
|
||||
$grant = trim($data['grant'] ?? '');
|
||||
|
||||
if ($grantType !== 'mobile') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'grant_type نامعتبر است', 400);
|
||||
}
|
||||
if ($grant === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'grant الزامی است', 422);
|
||||
}
|
||||
|
||||
$mobile = $this->otpService->consumeGrant($grant);
|
||||
|
||||
$user = $this->userRepo->findByMobile($mobile) ?? new User($mobile);
|
||||
$this->userRepo->save($user);
|
||||
$this->otpService->deleteOtp($uuid);
|
||||
|
||||
return new JsonResponse($this->tokenService->issueTokens($user));
|
||||
}
|
||||
@@ -352,38 +363,44 @@ class AuthController extends BaseController
|
||||
#[Route('/api/v1/user/otp-login', methods: ['POST'])]
|
||||
public function otpLogin(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
|
||||
if ($uuid === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'uuid الزامی است', 422);
|
||||
if ($resp = $this->enforceLimit($this->tokenIssueLimiter, $request)) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$otpData = $this->otpService->getVerifiedOtpData($uuid);
|
||||
$user = $this->userRepo->findByMobile($otpData['mobile']);
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$grant = trim($data['grant'] ?? '');
|
||||
|
||||
if ($grant === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'grant الزامی است', 422);
|
||||
}
|
||||
|
||||
$mobile = $this->otpService->consumeGrant($grant);
|
||||
$user = $this->userRepo->findByMobile($mobile);
|
||||
|
||||
if (!$user) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_005, 'کاربری با این شماره یافت نشد', 401);
|
||||
}
|
||||
|
||||
$this->otpService->deleteOtp($uuid);
|
||||
|
||||
return new JsonResponse($this->tokenService->issueTokens($user));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/user/reset-password', methods: ['POST'])]
|
||||
public function resetPassword(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
$newPassword = trim($data['new_password'] ?? '');
|
||||
|
||||
if ($uuid === '' || mb_strlen($newPassword) < 6) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'uuid و رمز عبور (حداقل ۶ کاراکتر) الزامی است', 422);
|
||||
if ($resp = $this->enforceLimit($this->passwordResetLimiter, $request)) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$otpData = $this->otpService->getVerifiedOtpData($uuid);
|
||||
$user = $this->userRepo->findByMobile($otpData['mobile']);
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$grant = trim($data['grant'] ?? '');
|
||||
$newPassword = trim($data['new_password'] ?? '');
|
||||
|
||||
if ($grant === '' || mb_strlen($newPassword) < 8) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'grant و رمز عبور (حداقل ۸ کاراکتر) الزامی است', 422);
|
||||
}
|
||||
|
||||
$mobile = $this->otpService->consumeGrant($grant);
|
||||
$user = $this->userRepo->findByMobile($mobile);
|
||||
|
||||
if (!$user) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کاربری با این شماره یافت نشد', 404);
|
||||
@@ -391,7 +408,6 @@ class AuthController extends BaseController
|
||||
|
||||
$user->setPasswordHash($this->hasher->hashPassword($user, $newPassword));
|
||||
$this->em->flush();
|
||||
$this->otpService->deleteOtp($uuid);
|
||||
|
||||
return $this->success(['message' => 'رمز عبور با موفقیت تغییر یافت']);
|
||||
}
|
||||
@@ -603,6 +619,15 @@ class AuthController extends BaseController
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function enforceLimit(RateLimiterFactory $factory, Request $request): ?JsonResponse
|
||||
{
|
||||
$limiter = $factory->create($request->getClientIp() ?? 'unknown');
|
||||
if (!$limiter->consume(1)->isAccepted()) {
|
||||
return $this->error(ErrorCodes::ERR_RATE_LIMIT_001, ErrorCodes::message(ErrorCodes::ERR_RATE_LIMIT_001), 429);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolvePrimaryRole(User $user): string
|
||||
{
|
||||
$roles = $user->getRoles();
|
||||
|
||||
Reference in New Issue
Block a user