fix(security): per-mobile OTP cap + refresh-token rotation (M6, M7)

M6: send-code rate-limited only per IP, so a victim's number could be
SMS-flooded from rotating IPs. Add a per-mobile bucket (same 5/hour policy)
keyed by the validated mobile.

M7: /oauth/token/refresh reused the presented refresh token verbatim (no
rotation) and never re-checked the user. The rotation infra already existed
(issueTokens mints a fresh refresh token) — the controller just discarded it.
Now revoke the presented token (single-use), issue a fresh pair, and reject a
suspended user (status != 1).

Regressions: tests/Auth/SendCodeMobileRateLimitTest,
tests/Auth/RefreshTokenRotationTest (both fail without the fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-28 20:25:22 +03:30
co-authored by Claude Opus 4.8
parent fe6383314e
commit 670cef24f4
5 changed files with 122 additions and 9 deletions
+12 -4
View File
@@ -147,6 +147,13 @@ class AuthController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت شماره موبایل نادرست است', 422, 'mobile');
}
// Per-mobile cap (in addition to per-IP) so a victim's number can't be
// SMS-flooded from rotating IPs.
$mobileLimiter = $this->sendCodeLimiter->create('mobile:' . $mobile);
if (!$mobileLimiter->consume(1)->isAccepted()) {
return $this->error(ErrorCodes::ERR_RATE_LIMIT_001, ErrorCodes::message(ErrorCodes::ERR_RATE_LIMIT_001), 429);
}
$uuid = $this->otpService->sendCode($mobile);
return new JsonResponse(['uuid' => $uuid, 'message' => 'کد تایید با موفقیت ارسال شد.']);
@@ -470,14 +477,15 @@ class AuthController extends BaseController
$result = $this->tokenService->refreshToken($refreshToken);
$user = $this->userRepo->find($result['userId']);
if ($user === null) {
if ($user === null || $user->getStatus() !== 1) {
return $this->error(ErrorCodes::ERR_AUTH_001, ErrorCodes::message(ErrorCodes::ERR_AUTH_001), 401);
}
$tokens = $this->tokenService->issueTokens($user);
$tokens['refresh_token'] = $result['rawToken'];
// Rotate: the presented refresh token is single-use. Revoke it and issue a
// fresh access + refresh pair, so a stolen token can't be reused.
$this->tokenService->revokeRefreshToken($refreshToken);
return new JsonResponse($tokens);
return new JsonResponse($this->tokenService->issueTokens($user));
}
#[OA\Get(