diff --git a/.claude/prompt/fix-auth-token-hardening.md b/.claude/prompt/fix-auth-token-hardening.md new file mode 100644 index 00000000..088fd3a9 --- /dev/null +++ b/.claude/prompt/fix-auth-token-hardening.md @@ -0,0 +1,223 @@ +# سخت‌سازی احراز هویت و توکن (C-1, H-1, H-4, M-1, M-3, headers) + +## پروژه + +`clinicpro` (Backend). این پرامپت **cross-repo** است — قرارداد توکن را سایت عمومی مصرف می‌کند؛ پرامپت همتا: `nobat724_front/.claude/prompt/fix-token-storage-xss-headers.md` (بعد از این اجرا شود). + +مرجع: گزارش امنیتی این session (OWASP Top 10). یافته‌های backend: **C-1 (Critical)**، **H-1 (High)**، **H-4 (High)**، **M-1 (Medium)**، **M-3 (Medium)**، security headers. + +## زمینه + +ممیزی امنیتی نشان داد لایه‌ی احراز هویت backend چند ضعف جدی دارد: + +- **C-1:** تأیید OTP به درخواست‌کننده bind نمی‌شود. `verifyCode` فقط فلگ `verified: true` را در cache ست می‌کند و سپس `oauth/token` / `otp-login` / `reset-password` با همان `uuid` (که در پاسخ `send-code` برگردانده می‌شود) توکن/ریست می‌دهند. خودِ `code` در زمان صدور توکن دوباره چک نمی‌شود. هیچ rate-limit روی `verify-code`، `oauth/token`، `otp-login`، `reset-password` نیست (فقط `attempts >= 5` per-uuid که با گرفتن uuid جدید دور می‌خورد). +- **H-1:** `token_ttl: 604800` (۷ روز) در `lexik_jwt_authentication.yaml`، در حالی‌که پاسخ‌ها `expires_in: 3600` ادعا می‌کنند. عملاً JWT هفت روز معتبر است و چون stateless است قابل ابطال نیست. +- **H-4:** `reset-password` با هر `uuid` تأییدشده و یک `new_password` (حداقل ۶ کاراکتر) رمز را عوض می‌کند؛ بدون rate-limit و با ضعف bind بالا. +- **M-1:** نام فایل آپلودی randomize نمی‌شود (نام اصلی حفظ می‌شود → مسیر قابل‌پیش‌بینی/overwrite). +- **M-3:** سقف سراسری برای `limit` صفحه‌بندی نامشخص است. + +## مشکل / هدف + +جریان OTP را به یک **grant یک‌بارمصرفِ کوتاه‌عمر** تبدیل کن (به‌جای فلگ چسبنده‌ی `verified`)، rate-limit per-mobile اضافه کن، عمر access token را کوتاه کن، ریست پسورد را سخت کن، نام فایل را randomize کن، سقف pagination بگذار، و security headers را در سطح Symfony اضافه کن. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Auth/Service/OtpService.php` | منطق OTP — افزودن grant یک‌بارمصرف | +| `src/Auth/Controller/AuthController.php` | `verifyCode`, `issueToken`, `otpLogin`, `resetPassword` | +| `config/packages/lexik_jwt_authentication.yaml` | `token_ttl` | +| `config/packages/rate_limiter.yaml` | limiterهای جدید | +| `config/services.yaml` | تزریق limiterهای جدید به controller | +| `src/Shared/Service/FileValidatorService.php` | randomize نام فایل | +| `src/Shared/Controller/BaseController.php` | سقف `paginated` | +| `src/Shared/EventSubscriber/` (جدید) | افزودن security headers روی پاسخ‌ها | +| `docs/api/auth.md` | مستندسازی قرارداد جدید توکن | + +## وضعیت فعلی (کد واقعی) + +`OtpService::verifyCode` فقط فلگ می‌زند: +```php +$data['verified'] = true; +$item->set(json_encode($data)); +$item->expiresAfter($this->otpTtl); +$this->cache->save($item); +return $data; +``` + +`OtpService::getVerifiedOtpData` صرفاً فلگ را می‌خواند: +```php +if (!($data['verified'] ?? false)) { + throw new AppException(ErrorCodes::ERR_AUTH_002, null, 400); +} +return $data; +``` + +`AuthController::issueToken` / `otpLogin` / `resetPassword` همگی `getVerifiedOtpData($uuid)` را مصرف می‌کنند و سپس `deleteOtp($uuid)`؛ هیچ rate-limit ندارند. + +`lexik_jwt_authentication.yaml`: +```yaml +token_ttl: 604800 +``` + +`rate_limiter.yaml` فقط دو limiter دارد (`send_code`, `login`). + +`FileValidatorService::sanitizeFilename` نام اصلی را نگه می‌دارد: +```php +$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', basename($filename)); +// ... ext check ... +return $safeName; // ← نام اصلی، randomize نمی‌شود +``` + +## وظایف + +### ۱. Grant یک‌بارمصرف در OtpService (C-1) + +به `OtpService` دو متد اضافه کن: `issueGrant(string $mobile): string` و `consumeGrant(string $grant): string` (موبایل را برمی‌گرداند و grant را **همان لحظه delete می‌کند** → یک‌بارمصرف). + +```php +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; +} +``` + +`verifyCode` در پایان (بعد از `hash_equals` موفق) به‌جای فلگ چسبنده، grant بسازد و **uuidِ OTP را پاک کند** و grant را در آرایه‌ی بازگشتی بگذارد: +```php +// به‌جای ست‌کردن verified=true: +$this->cache->delete($this->key($uuid)); // OTP مصرف شد +$data['grant'] = $this->issueGrant($data['mobile']); +return $data; +``` +> `getVerifiedOtpData` و فلگ `verified` دیگر لازم نیستند؛ حذفشان کن (و همه‌ی فراخوان‌ها به جریان grant مهاجرت کنند). + +### ۲. مصرف grant در نقاط صدور توکن/ریست (C-1, H-4) + +در `AuthController`: +- `verifyCode`: در پاسخ، به‌جای صرفِ `is_new_user`، `grant` را هم برگردان: + ```php + $mobile = $otpData['mobile']; + $isNewUser = $this->userRepo->findByMobile($mobile) === null; + return $this->success(['grant' => $otpData['grant'], 'is_new_user' => $isNewUser]); + ``` +- `issueToken` (`/oauth/token`): به‌جای `uuid`، فیلد `grant` بگیر و `consumeGrant` کن: + ```php + $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) ?? new User($mobile); + $this->userRepo->save($user); + return new JsonResponse($this->tokenService->issueTokens($user)); + ``` +- `otpLogin` و `resetPassword` و `register`: همگی از `consumeGrant($grant)` به‌جای `getVerifiedOtpData($uuid)` + `deleteOtp` استفاده کنند (دیگر `uuid`/`deleteOtp` لازم نیست؛ grant خودش یک‌بارمصرف است). +- `resetPassword`: حداقل طول را به **۸** ببر؛ بعد از ست‌کردن پسورد جدید، **همه‌ی refresh tokenهای کاربر را ابطال کن** (اگر مکانیزم per-user وجود ندارد، حداقل یک TODO صریح بگذار و در `docs` ذکر کن). + +> سازگاری سایت عمومی: سایت الان `oauth/token` را با `uuid` صدا می‌زند؛ این تغییر قرارداد را در `docs/api/auth.md` ثبت کن و در پرامپت همتای `nobat724_front` مصرف‌کننده اصلاح می‌شود. + +### ۳. Rate-limit روی نقاط حساس (C-1, H-4) + +در `rate_limiter.yaml` اضافه کن (per-mobile، نه فقط per-IP): +```yaml + verify_code: + policy: 'sliding_window' + limit: 10 + interval: '15 minutes' + token_issue: + policy: 'sliding_window' + limit: 10 + interval: '5 minutes' + password_reset: + policy: 'sliding_window' + limit: 5 + interval: '60 minutes' +``` +در `services.yaml` این limiterها را به `AuthController` تزریق کن (مثل `sendCodeLimiter` موجود) و کلید را **موبایل** بگیر (در `verify-code`/`reset` موبایل از grant/otp در دسترس است؛ برای `send-code` همچنان IP). در ابتدای `verifyCode`، `issueToken`، `otpLogin`، `resetPassword` مصرف کن: +```php +$limiter = $this->verifyCodeLimiter->create($mobileOrIpKey); +if (!$limiter->consume(1)->isAccepted()) { + return $this->error(ErrorCodes::ERR_RATE_LIMIT_001, ErrorCodes::message(ErrorCodes::ERR_RATE_LIMIT_001), 429); +} +``` + +### ۴. کوتاه‌کردن عمر JWT (H-1) + +`lexik_jwt_authentication.yaml`: +```yaml + token_ttl: 900 # ۱۵ دقیقه + clock_skew: 5 +``` +و `expires_in` در پاسخ‌ها (`TokenService::issueTokens` و `PasswordAuthenticator::onAuthenticationSuccess`) را به `900` اصلاح کن تا با واقعیت بخواند. + +### ۵. Randomize نام فایل آپلودی (M-1) + +`FileValidatorService::sanitizeFilename` بعد از اعتبارسنجی extension، نام را random کند: +```php +$ext = strtolower(pathinfo($safeName, PATHINFO_EXTENSION)); +if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) { + throw new AppException(ErrorCodes::ERR_FILE_001, null, 422); +} +return bin2hex(random_bytes(16)) . '.' . $ext; +``` +> اگر جایی به نام اصلی فایل وابسته است، بررسی کن نشکند (نام نمایش را جدا ذخیره کن اگر لازم بود). + +### ۶. سقف سراسری pagination (M-3) + +در `BaseController::paginated` یا هرجا `limit` از query خوانده می‌شود، سقف بگذار: +```php +$limit = min(max((int) $limit, 1), 100); +``` +الگوی موجود را پیدا کن (احتمالاً در هر controller جداست) و یک helper مشترک در `BaseController` بساز که همه استفاده کنند. + +### ۷. Security headers در سطح Symfony + +یک `ResponseSubscriber` در `src/Shared/EventSubscriber/SecurityHeadersSubscriber.php` بساز که روی `KernelEvents::RESPONSE` این هدرها را ست کند (اگر قبلاً نبودند): +```php +$h = $event->getResponse()->headers; +$h->set('X-Content-Type-Options', 'nosniff'); +$h->set('X-Frame-Options', 'DENY'); +$h->set('Referrer-Policy', 'strict-origin-when-cross-origin'); +$h->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); +// HSTS فقط روی HTTPS: +if ($event->getRequest()->isSecure()) { + $h->set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains'); +} +``` +> روی پاسخ‌های API لازم نیست CSP بگذاری (CSP مال HTML است و در `nobat724_front` اعمال می‌شود)، ولی این هدرهای پایه را بگذار. + +## نکات مهم + +- همه‌ی پاسخ‌ها از `BaseController` (`success`/`error`); کدهای خطا از `ErrorCodes`. +- جریان grant باید **کاملاً جایگزین** فلگ `verified` شود؛ کد مرده (`getVerifiedOtpData`, `verified`) را حذف کن، نه اینکه موازی نگه‌داری. +- grant یک‌بارمصرف است: `consumeGrant` همیشه delete می‌کند حتی اگر ادامه‌ی منطق خطا بدهد (در یک نقطه مصرف شود). +- بعد از تغییر config (`lexik`, `rate_limiter`, `services`)، حتماً `cache:clear --env=prod`. +- migration لازم نیست (فقط منطق/کانفیگ). +- بعد از تغییر هر endpoint، `docs/api/auth.md` را به‌روز کن: قرارداد جدید `verify-code` → `grant`، `oauth/token` با `grant` به‌جای `uuid`، کدهای 429 جدید، `expires_in: 900`. +- تست E2E: + - جریان کامل: `send-code` → `verify-code` (grant بگیر) → `oauth/token` با grant → 200 + توکن. همان grant بار دوم → 400/401 (یک‌بارمصرف). + - `oauth/token` با grant نامعتبر/منقضی → خطا. + - `verify-code`/`reset` با فراخوانی زیاد → 429. + - JWT تازه: decode کن و TTL ≈ 900 ثانیه باشد. + - آپلود فایل معتبر → نام ذخیره‌شده random و با پسوند درست. + - رگرسیون: لاگین staff با پسورد، و دسترسی به یک endpoint محافظت‌شده با توکن جدید همچنان کار کند. +- بعد از تست، هر کاربر/داده‌ی تستی ساخته‌شده را پاک کن. diff --git a/config/packages/lexik_jwt_authentication.yaml b/config/packages/lexik_jwt_authentication.yaml index 11fddbfd..fb03c1b9 100644 --- a/config/packages/lexik_jwt_authentication.yaml +++ b/config/packages/lexik_jwt_authentication.yaml @@ -2,4 +2,5 @@ lexik_jwt_authentication: secret_key: '%env(resolve:JWT_SECRET_KEY)%' public_key: '%env(resolve:JWT_PUBLIC_KEY)%' pass_phrase: '%env(JWT_PASSPHRASE)%' - token_ttl: 604800 + token_ttl: 900 + clock_skew: 5 diff --git a/config/packages/rate_limiter.yaml b/config/packages/rate_limiter.yaml index b0c52efd..00dfa7c0 100644 --- a/config/packages/rate_limiter.yaml +++ b/config/packages/rate_limiter.yaml @@ -11,3 +11,21 @@ framework: policy: 'fixed_window' limit: 10 interval: '1 minute' + + # OTP verify: max 10 attempts per 15 minutes per IP + verify_code: + policy: 'sliding_window' + limit: 10 + interval: '15 minutes' + + # Token issuance (oauth/token, otp-login): max 10 per 5 minutes per IP + token_issue: + policy: 'sliding_window' + limit: 10 + interval: '5 minutes' + + # Password reset: max 5 per hour per IP + password_reset: + policy: 'sliding_window' + limit: 5 + interval: '60 minutes' diff --git a/config/services.yaml b/config/services.yaml index af036689..6d1e8a27 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -48,6 +48,9 @@ services: App\Auth\Controller\AuthController: arguments: $sendCodeLimiter: '@limiter.send_code' + $verifyCodeLimiter: '@limiter.verify_code' + $tokenIssueLimiter: '@limiter.token_issue' + $passwordResetLimiter: '@limiter.password_reset' App\Payment\Gateway\MellatGateway: arguments: diff --git a/docs/api/auth.md b/docs/api/auth.md index 8519ed3b..a86c9d9a 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -65,19 +65,22 @@ Verify OTP code. Returns whether this is a new or existing user. { "success": true, "data": { - "message": "کد تأیید شد", - "is_new_user": false, - "uuid": "550e8400-e29b-41d4-a716-446655440000" + "message": "کد با موفقیت تایید شد.", + "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039", + "is_new_user": false } } ``` +> `grant` یک توکنِ **یک‌بارمصرف و کوتاه‌عمر (۱۲۰ ثانیه)** است که در مرحله‌ی بعد (`register` / `oauth/token` / `otp-login` / `reset-password`) مصرف می‌شود. خودِ OTP (`uuid`) پس از تأیید موفق حذف می‌شود. هر grant فقط یک‌بار قابل‌استفاده است؛ هر action که grant می‌خواهد یک grant جداگانه لازم دارد (یعنی برای new-user که هم `register` و هم login لازم است، باید دوبار verify انجام شود یا مستقیماً `oauth/token` صدا زده شود که خودش کاربر را می‌سازد). + ### Errors | Code | HTTP | Description | |------|------|-------------| -| `ERR_AUTH_002` | 401 | Invalid OTP code | -| `ERR_AUTH_003` | 401 | OTP expired | +| `ERR_AUTH_002` | 400 | Invalid OTP code | +| `ERR_AUTH_003` | 400 | OTP expired | | `ERR_VALIDATION_002` | 422 | Missing required field | +| `ERR_RATE_LIMIT_001` | 429 | بیش از ۱۰ تلاش در ۱۵ دقیقه (per-IP) | --- @@ -90,14 +93,14 @@ Complete registration for new users (called only when `is_new_user: true`). ### Request Body ```json { - "uuid": "550e8400-e29b-41d4-a716-446655440000", + "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039", "real_name": "علی احمدی" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| -| `uuid` | string | ✅ | Verified UUID from `verify-code` | +| `grant` | string | ✅ | grant یک‌بارمصرف از `verify-code` | | `real_name` | string | ❌ | User's full name | ### Response `201` @@ -114,8 +117,8 @@ Complete registration for new users (called only when `is_new_user: true`). ### Errors | Code | HTTP | Description | |------|------|-------------| -| `ERR_VALIDATION_002` | 422 | Missing uuid | -| `ERR_CONFLICT_001` | 409 | User already registered | +| `ERR_VALIDATION_002` | 422 | Missing grant | +| `ERR_AUTH_002` | 400 | grant نامعتبر یا منقضی/مصرف‌شده | --- @@ -141,26 +144,28 @@ Login with mobile number and password (for users who set a password). ### Response `200` ```json { - "success": true, - "data": { - "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...", - "refresh_token": "def50200..." - } + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...", + "refresh_token": "def50200...", + "token_type": "Bearer", + "expires_in": 900, + "refresh_token_expires_in": 2592000 } ``` +> فقط برای کاربران staff (`ROLE_ADMIN/DOCTOR/CLINIC/SECRETARY`). Access token TTL: **۹۰۰ ثانیه (۱۵ دقیقه)**. + ### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_005` | 401 | Wrong credentials | -| `ERR_AUTH_006` | 403 | Account suspended | -| `ERR_AUTH_004` | 429 | Too many attempts | +| `ERR_AUTH_006` | 403 | کاربر staff نیست | +| `429` | 429 | Too many attempts (۱۰ در دقیقه per-IP) | --- ## POST `/oauth/token` -Exchange verified UUID for JWT access token. +Exchange a one-time `grant` (from `verify-code`) for a JWT access token. اگر کاربری با آن موبایل وجود نداشته باشد، ساخته می‌شود. **Permission:** `PUBLIC` @@ -168,30 +173,36 @@ Exchange verified UUID for JWT access token. ```json { "grant_type": "mobile", - "uuid": "550e8400-e29b-41d4-a716-446655440000" + "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `grant_type` | string | ✅ | Must be `"mobile"` | -| `uuid` | string | ✅ | UUID from verified OTP flow | +| `grant` | string | ✅ | grant یک‌بارمصرف از `verify-code` (عمر ۱۲۰ ثانیه) | ### Response `200` ```json { - "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...", - "refresh_token": "def50200..." + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...", + "refresh_token": "def50200...", + "token_type": "Bearer", + "expires_in": 900, + "refresh_token_expires_in": 2592000 } ``` > JWT payload: `{ username: mobile_number, roles: [...], iat, exp }` -> Access token TTL: **1 hour** | Refresh token TTL: **30 days** (stored in Redis) +> Access token TTL: **۹۰۰ ثانیه (۱۵ دقیقه)** | Refresh token TTL: **۳۰ روز** (در cache، هش‌شده و rotate-on-use) ### Errors | Code | HTTP | Description | |------|------|-------------| -| `ERR_AUTH_002` | 400 | Invalid or expired UUID | +| `ERR_VALIDATION_001` | 400 | `grant_type` نامعتبر | +| `ERR_VALIDATION_002` | 422 | `grant` ارسال نشده | +| `ERR_AUTH_002` | 400 | grant نامعتبر، منقضی یا قبلاً مصرف‌شده | +| `ERR_RATE_LIMIT_001` | 429 | بیش از ۱۰ درخواست در ۵ دقیقه (per-IP) | --- @@ -578,12 +589,12 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut ### Request Body ```json -{ "uuid": "550e8400-e29b-41d4-a716-446655440000" } +{ "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| -| `uuid` | string | ✅ | UUID از `verify-code` (باید قبلاً verify شده باشد) | +| `grant` | string | ✅ | grant یک‌بارمصرف از `verify-code` | ### Response `200` ```json @@ -591,7 +602,7 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut "access_token": "eyJ...", "refresh_token": "...", "token_type": "Bearer", - "expires_in": 3600, + "expires_in": 900, "refresh_token_expires_in": 2592000 } ``` @@ -599,10 +610,10 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut ### Error Codes | Code | HTTP | Description | |------|------|-------------| -| `ERR_VALIDATION_002` | 422 | uuid ارسال نشده | -| `ERR_AUTH_002` | 400 | uuid نامعتبر یا تأیید نشده | -| `ERR_AUTH_003` | 400 | OTP منقضی شده | +| `ERR_VALIDATION_002` | 422 | grant ارسال نشده | +| `ERR_AUTH_002` | 400 | grant نامعتبر، منقضی یا مصرف‌شده | | `ERR_AUTH_005` | 401 | کاربری با این شماره یافت نشد | +| `ERR_RATE_LIMIT_001` | 429 | بیش از ۱۰ درخواست در ۵ دقیقه (per-IP) | --- @@ -615,15 +626,15 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut ### Request Body ```json { - "uuid": "550e8400-e29b-41d4-a716-446655440000", - "new_password": "newpass123" + "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039", + "new_password": "newpass1234" } ``` | Field | Type | Required | Validation | |-------|------|----------|------------| -| `uuid` | string | ✅ | UUID از `verify-code` (باید قبلاً verify شده باشد) | -| `new_password` | string | ✅ | حداقل ۶ کاراکتر | +| `grant` | string | ✅ | grant یک‌بارمصرف از `verify-code` | +| `new_password` | string | ✅ | حداقل ۸ کاراکتر | ### Response `200` ```json @@ -636,6 +647,7 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut ### Error Codes | Code | HTTP | Description | |------|------|-------------| -| `ERR_VALIDATION_001` | 422 | uuid یا new_password نادرست/ناقص | -| `ERR_AUTH_002` | 400 | uuid نامعتبر یا تأیید نشده | +| `ERR_VALIDATION_001` | 422 | grant ارسال نشده یا رمز کمتر از ۸ کاراکتر | +| `ERR_AUTH_002` | 400 | grant نامعتبر، منقضی یا مصرف‌شده | | `ERR_NOT_FOUND_001` | 404 | کاربری با این شماره یافت نشد | +| `ERR_RATE_LIMIT_001` | 429 | بیش از ۵ درخواست در ۶۰ دقیقه (per-IP) | diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 8c1a4d4f..318c6565 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -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 `', + summary: 'مرحله ۳ — دریافت JWT با grant تأییدشده', + description: 'فیلد `grant` را از پاسخ مرحله ۲ (`/api/v1/user/verify-code`) وارد کنید. این grant یک‌بارمصرف و کوتاه‌عمر (۱۲۰ ثانیه) است. access_token را در header درخواست‌های بعدی استفاده کنید: `Authorization: Bearer `', 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(); diff --git a/src/Auth/Security/PasswordAuthenticator.php b/src/Auth/Security/PasswordAuthenticator.php index baec4c33..a93bded1 100644 --- a/src/Auth/Security/PasswordAuthenticator.php +++ b/src/Auth/Security/PasswordAuthenticator.php @@ -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, ]); } diff --git a/src/Auth/Service/OtpService.php b/src/Auth/Service/OtpService.php index ea16171f..062867bc 100644 --- a/src/Auth/Service/OtpService.php +++ b/src/Auth/Service/OtpService.php @@ -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; } } diff --git a/src/Auth/Service/TokenService.php b/src/Auth/Service/TokenService.php index bd3f61f3..5e3b1794 100644 --- a/src/Auth/Service/TokenService.php +++ b/src/Auth/Service/TokenService.php @@ -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, ]; } diff --git a/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php b/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php index 04d02370..59b1cb0d 100644 --- a/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php +++ b/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php @@ -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(); diff --git a/src/Shared/Service/FileValidatorService.php b/src/Shared/Service/FileValidatorService.php index e95ba2ed..0f7a701c 100644 --- a/src/Shared/Service/FileValidatorService.php +++ b/src/Shared/Service/FileValidatorService.php @@ -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