12 KiB
سختسازی احراز هویت و توکن (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 >= 5per-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 فقط فلگ میزند:
$data['verified'] = true;
$item->set(json_encode($data));
$item->expiresAfter($this->otpTtl);
$this->cache->save($item);
return $data;
OtpService::getVerifiedOtpData صرفاً فلگ را میخواند:
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:
token_ttl: 604800
rate_limiter.yaml فقط دو limiter دارد (send_code, login).
FileValidatorService::sanitizeFilename نام اصلی را نگه میدارد:
$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 میکند → یکبارمصرف).
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 را در آرایهی بازگشتی بگذارد:
// بهجای ستکردن 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را هم برگردان:$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کن:$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):
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 مصرف کن:
$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:
token_ttl: 900 # ۱۵ دقیقه
clock_skew: 5
و expires_in در پاسخها (TokenService::issueTokens و PasswordAuthenticator::onAuthenticationSuccess) را به 900 اصلاح کن تا با واقعیت بخواند.
۵. Randomize نام فایل آپلودی (M-1)
FileValidatorService::sanitizeFilename بعد از اعتبارسنجی extension، نام را random کند:
$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 خوانده میشود، سقف بگذار:
$limit = min(max((int) $limit, 1), 100);
الگوی موجود را پیدا کن (احتمالاً در هر controller جداست) و یک helper مشترک در BaseController بساز که همه استفاده کنند.
۷. Security headers در سطح Symfony
یک ResponseSubscriber در src/Shared/EventSubscriber/SecurityHeadersSubscriber.php بساز که روی KernelEvents::RESPONSE این هدرها را ست کند (اگر قبلاً نبودند):
$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 محافظتشده با توکن جدید همچنان کار کند.
- جریان کامل:
- بعد از تست، هر کاربر/دادهی تستی ساختهشده را پاک کن.