feat: implement short-lived grant system for OTP verification and enhance rate limiting across authentication endpoints

This commit is contained in:
hamed
2026-06-20 12:51:10 +03:30
parent f9678026a8
commit e2636ce743
11 changed files with 396 additions and 107 deletions
+65 -40
View File
@@ -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();
+1 -1
View File
@@ -84,7 +84,7 @@ class PasswordAuthenticator extends AbstractAuthenticator
'access_token' => $accessToken,
'refresh_token' => $rawToken,
'token_type' => 'Bearer',
'expires_in' => 3600,
'expires_in' => 900,
'refresh_token_expires_in' => $this->refreshTokenTtl,
]);
}
+34 -27
View File
@@ -26,6 +26,36 @@ class OtpService
return 'otp_' . str_replace('-', '_', $uuid);
}
private function grantKey(string $grant): string
{
return 'otp_grant_' . $grant;
}
public function issueGrant(string $mobile): string
{
$grant = bin2hex(random_bytes(32));
$item = $this->cache->getItem($this->grantKey($grant));
$item->set($mobile);
$item->expiresAfter(120);
$this->cache->save($item);
return $grant;
}
public function consumeGrant(string $grant): string
{
$item = $this->cache->getItem($this->grantKey($grant));
if (!$item->isHit()) {
throw new AppException(ErrorCodes::ERR_AUTH_002, null, 400);
}
$mobile = $item->get();
$this->cache->delete($this->grantKey($grant));
return $mobile;
}
public function sendCode(string $mobile): string
{
$uuid = Uuid::v4()->toRfc4122();
@@ -69,33 +99,10 @@ class OtpService
throw new AppException(ErrorCodes::ERR_AUTH_002, null, 400);
}
$data['verified'] = true;
$item->set(json_encode($data));
$item->expiresAfter($this->otpTtl);
$this->cache->save($item);
return $data;
}
public function getVerifiedOtpData(string $uuid): array
{
$item = $this->cache->getItem($this->key($uuid));
if (!$item->isHit()) {
throw new AppException(ErrorCodes::ERR_AUTH_003, null, 400);
}
$data = json_decode($item->get(), true);
if (!($data['verified'] ?? false)) {
throw new AppException(ErrorCodes::ERR_AUTH_002, null, 400);
}
return $data;
}
public function deleteOtp(string $uuid): void
{
$this->cache->delete($this->key($uuid));
$data['grant'] = $this->issueGrant($data['mobile']);
return $data;
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ class TokenService
'access_token' => $accessToken,
'refresh_token' => $rawToken,
'token_type' => 'Bearer',
'expires_in' => 3600,
'expires_in' => 900,
'refresh_token_expires_in' => $this->refreshTokenTtl,
];
}
@@ -22,7 +22,7 @@ class SecurityHeadersSubscriber implements EventSubscriberInterface
$response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
if ($event->getRequest()->isSecure()) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
$response->headers->set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
}
$path = $event->getRequest()->getPathInfo();
+1 -1
View File
@@ -62,7 +62,7 @@ class FileValidatorService
throw new AppException(ErrorCodes::ERR_FILE_001, null, 422);
}
return $safeName;
return bin2hex(random_bytes(16)) . '.' . $ext;
}
public function detectMimeType(string $filePath): string