# سخت‌سازی احراز هویت و توکن (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 محافظت‌شده با توکن جدید همچنان کار کند. - بعد از تست، هر کاربر/داده‌ی تستی ساخته‌شده را پاک کن.