fix: D4 — حدّ الطلبات كان مُعطَّلاً كلياً رغم أنه يبدو مفعَّلاً

ThrottlerModule.forRoot(...) كان مسجَّلاً في app.module.ts منذ البداية —
بلا أي حارس يطبّقه (لا APP_GUARD ولا @UseGuards). التسجيل وحده لا يفعل شيئاً
في NestJS: كل نقطة في الـAPI، بما فيها verify-otp و payouts/*، كانت بلا أي
حدّ طلبات إطلاقاً منذ اليوم الأول.

- APP_GUARD → ThrottlerGuard يُفعّل الحدّ العام (120/دقيقة) على كل نقطة
- عدّاد محاولات لكل (مستأجر، رقم) في AuthService.verifyOtp — الحماية
  الحقيقية ضد تخمين الرمز لا الحدّ العام: رمز 4 خانات = 10000 احتمال،
  ومهاجم يدوّر IP يتجاوز أي حدّ بالـIP وحده. 5 محاولات ثم إبطال الرمز،
  نفس نمط payouts.service (I4)
- حدود أضيق للأهداف عالية القيمة: send-otp (كل إرسال يكلّف رسالة واتساب
  مدفوعة فعلياً)، verify-otp، payouts/request، payouts/confirm
- HealthController مُستثنى (@SkipThrottle) — مراقبة تشغيل بلا بيانات حساسة

قرار وعي بالمخاطرة: بنيت أولاً حارساً مخصَّصاً يتتبّع بالمستخدم المصادَق
لا بالـIP وحده (مهم لموبايل — NAT عند مشغّلي الجوّال يجمع آلاف المستخدمين
خلف IP واحد)، لكن تطلّب حقناً يدوياً دقيقاً (InjectThrottlerOptions/
InjectThrottlerStorage) — خطأ فيه يمنع إقلاع التطبيق كاملاً، ولا بيئة هنا
لاختبار NestFactory.create() قبل الدفع (jest ينشئ الخدمات يدوياً فلا يكشف
أخطاء DI لحارس عالمي). حذفته ورجّحت الأمان بالتتبّع الافتراضي — موثّق في
docs/17 D4 لتُنفَّذ حين يمكن اختبارها فعلياً على السيرفر.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 17:47:03 +03:00
co-authored by Claude Opus 4.8
parent 253fbb4b2a
commit c258a8c4b8
6 changed files with 61 additions and 2 deletions
+23 -1
View File
@@ -42,6 +42,16 @@ export class AuthService {
return `otp:${tenantId}:${phone}`;
}
private otpAttemptsKey(tenantId: string, phone: string): string {
return `otp:attempts:${tenantId}:${phone}`;
}
// حدّ محاولات لكل (مستأجر، رقم) — لا لكل IP (docs/17 — D4). حارس الطلبات
// العام (ThrottlerGuard) يُبطئ مهاجماً واحداً من عنوان واحد؛ هذا يمنعه حتى
// لو دوّر عناوين IP، لأن رمزاً من 4 خانات = 10000 احتمال يُخمَّن في دقائق
// بلا هذا الحدّ. نفس النمط المستعمل في payouts.service (I4).
private readonly OTP_MAX_ATTEMPTS = 5;
private genCode(): string {
if (this.devMode) return '1234';
const len = this.config.get<number>('auth.otpLength') ?? 4;
@@ -80,11 +90,23 @@ export class AuthService {
// في وضع التطوير: الرمز الثابت 1234 يمرّ دائماً (تسهيل الاختبار).
const devBypass = this.devMode && code === '1234';
if (!devBypass) {
const attemptsKey = this.otpAttemptsKey(tenant.id, canonical);
const attempts = await this.redis.incr(attemptsKey);
if (attempts === 1) {
// نفس عمر الرمز — لا داعي لعدّاد يبقى بعد انتهاء صلاحية الرمز نفسه.
await this.redis.expire(attemptsKey, this.config.get<number>('auth.otpTtl') ?? 300);
}
if (attempts > this.OTP_MAX_ATTEMPTS) {
await this.redis.del(this.otpKey(tenant.id, canonical)); // إبطال الرمز فوراً
throw new UnauthorizedException('Too many attempts — request a new code');
}
const stored = await this.redis.get(this.otpKey(tenant.id, canonical));
if (!stored || stored !== code) {
throw new UnauthorizedException('Invalid or expired OTP code');
}
await this.redis.del(this.otpKey(tenant.id, canonical));
// نجاح — يُستهلك الرمز والعدّاد معاً؛ لا فائدة من عدّاد بعد رمز صحيح.
await this.redis.del(this.otpKey(tenant.id, canonical), attemptsKey);
}
let user = await this.usersService.findByPhone(tenant.id, canonical);