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
@@ -0,0 +1,34 @@
<?php
namespace App\Tests\Auth;
use App\Tests\ApiTestCase;
/**
* send-code must cap requests per mobile number, not only per IP — otherwise a
* victim's number can be SMS-flooded from rotating IPs.
*/
class SendCodeMobileRateLimitTest extends ApiTestCase
{
public function testPerMobileCapHoldsAcrossDifferentIps(): void
{
$this->client->disableReboot();
$mobile = '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT);
$statuses = [];
for ($i = 0; $i < 6; $i++) {
// each request from a different IP → the per-IP limiter never fires
$this->client->request(
'POST',
'/api/v1/user/send-code',
server: ['REMOTE_ADDR' => "10.20.30.$i", 'CONTENT_TYPE' => 'application/json'],
content: json_encode(['mobile' => $mobile]),
);
$statuses[] = $this->client->getResponse()->getStatusCode();
}
// send_code limit is 5/hour → the 6th for the same mobile is rejected
$this->assertSame(429, $statuses[5], 'per-mobile cap not enforced: ' . implode(',', $statuses));
$this->assertNotContains(429, array_slice($statuses, 0, 5));
}
}