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

12 KiB
Raw Blame History

سخت‌سازی احراز هویت و توکن (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 فقط فلگ می‌زند:

$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-codegrant، oauth/token با grant به‌جای uuid، کدهای 429 جدید، expires_in: 900.
  • تست E2E:
    • جریان کامل: send-codeverify-code (grant بگیر) → oauth/token با grant → 200 + توکن. همان grant بار دوم → 400/401 (یک‌بارمصرف).
    • oauth/token با grant نامعتبر/منقضی → خطا.
    • verify-code/reset با فراخوانی زیاد → 429.
    • JWT تازه: decode کن و TTL ≈ 900 ثانیه باشد.
    • آپلود فایل معتبر → نام ذخیره‌شده random و با پسوند درست.
    • رگرسیون: لاگین staff با پسورد، و دسترسی به یک endpoint محافظت‌شده با توکن جدید همچنان کار کند.
  • بعد از تست، هر کاربر/داده‌ی تستی ساخته‌شده را پاک کن.