Files
clinicpro/.claude/prompt/fix-auth-token-hardening.md
T

224 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# سخت‌سازی احراز هویت و توکن (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 محافظت‌شده با توکن جدید همچنان کار کند.
- بعد از تست، هر کاربر/داده‌ی تستی ساخته‌شده را پاک کن.