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();
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user