feat: D5 — OTP متعدد المزوّدين حسب الدولة (من سيرو) + قرار بلا كلمة مرور

مراجعة سيرو (auth/otp/): مصر تستعمل Kazumi SMS مع failover لواتساب،
وسوريا/الأردن Nabeh. عندنا كان مزوّد واحد فقط.

- integrations/otp/: واجهة OtpProvider + OtpDispatcher يوجّه حسب
  tenant.countryPack بسلسلة failover (eg: [kazumi, nabeh] · jo/sy: [nabeh])
- KazumiSmsProvider (مصر SMS) + NabehOtpProvider (غلاف على NabehService)
- كل مزوّد يرسل رمزاً نولّده نحن (Redis) — استبعدنا نمط Intaleq (يولّد الرمز
  بنفسه) حفاظاً على مصدر واحد للرمز
- موحَّد: AuthService.sendOtp و PayoutsService.issueOtp يمرّان بنفس المُوزِّع
  الآن — أُزيل كل استدعاء Nabeh مباشر. أي مسار يحتاج رسالة يستدعي الخدمة
  الموحّدة (طلب المالك)
- فشل السلسلة كاملة = ServiceUnavailable صريح، لا صمت

قرار المالك (2026-07-17): بلا كلمة مرور إطلاقاً. المصادقة = هاتف + OTP مرة
→ جلسة مربوطة بالجهاز (D2). التسجيل بالهاتف لا Google/Apple (محظوران في بعض
الدول). سيرو نفسه لا كلمة مرور حقيقية له (password = hash(email) وهمي،
ومقارنة نصّية غير آمنة في مسار التجربة). يطابق أوبر/كريم/inDrive.

مراجعة حدّ الطلبات في سيرو (RateLimiter.php): حدود مسمّاة لكل نوع + fallback
بملف عند تعطّل Redis (fail-closed). عندنا مسار OTP fail-closed أصلاً؛
تخزين Throttler على Redis مؤجَّل للتوسّع الأفقي الفعلي (موثّق في D4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 19:24:01 +03:00
co-authored by Claude Opus 4.8
parent c258a8c4b8
commit 8f6c46f0ac
13 changed files with 343 additions and 15 deletions
+11
View File
@@ -74,6 +74,15 @@
| D2 | **بصمة الجهاز** | 🟡 **مبنيّة ومطفأة** (`AUTH_REQUIRE_DEVICE_BINDING=false`) حتى يرسل فلاتر `x-device-id`. **مُنفَّذة داخل `JwtStrategy` نفسها لا كحارس منفصل** — يُفرض على كل نقطة محميّة تلقائياً، فلا نقطة منسيّة. توكن الدخول يحمل `hash(deviceId)` لا القيمة الخام؛ توكن مسروق من جهاز آخر يُرفض بمجرد تفعيل العلم. |
| D3 | **HMAC للعمليات الحساسة** | ✅ **منفَّذ ضمن I6** — `SigningService`/`SignatureGuard`، نفس البند لا تكرار. |
| D4 | **حدّ الطلبات (rate limiting)** | ✅ **إصلاح ثغرة قائمة** — راجع أدناه. |
| D5 | **OTP متعدد المزوّدين حسب الدولة** | ✅ — راجع أدناه. |
### D5 — توجيه OTP حسب الدولة + failover (من سيرو `auth/otp/`)
مراجعة سيرو أظهرت: مصر تستعمل **Kazumi SMS** (`sms.kazumi.me`) مع failover لواتساب، وسوريا/الأردن **Nabeh** (واتساب، توكن مُخزَّن 24س). عندنا كان مزوّد واحد (Nabeh) فقط.
- `integrations/otp/` — واجهة `OtpProvider` + `OtpDispatcher` يوجّه حسب `tenant.countryPack` بسلسلة failover (`eg: [kazumi, nabeh]` · `jo/sy: [nabeh]`). إضافة دولة/مزوّد = صنف موفّر + سطر في السلسلة.
- كل مزوّد يرسل **رمزاً نولّده نحن** (مخزَّن في Redis) — لا يولّده المزوّد. استبعدنا نمط Intaleq (يولّد الرمز ويرجعه) حفاظاً على مصدر واحد.
- **موحَّد لكل المسارات**: تسجيل الدخول (`AuthService`) والسحب (`PayoutsService`) يمرّان بنفس المُوزِّع الآن — لا استدعاء Nabeh مباشر في أي مكان.
- **قرار المالك (2026-07-17): بلا كلمة مرور إطلاقاً.** المصادقة = هاتف + OTP مرة → جلسة مربوطة بالجهاز (D2). لا حقل `password`، لا «نسيت كلمة المرور»، لا عبء دعم. سيرو نفسه لا كلمة مرور حقيقية له (`password = hash(email)` وهمي). يطابق أوبر/كريم/inDrive — المعيار في هذه الأسواق. **التسجيل بالهاتف لا Google/Apple** (محظوران في بعض الدول).
- **تصحيح لسيرو**: رمزه من **3 خانات** (1000 احتمال — ضعيف). عندنا 4 (قابل للرفع عبر `OTP_LENGTH`)، وعدّاد 5 محاولات (D4) يغلق التخمين.
### D4 — حدّ الطلبات: كان مُعطَّلاً كلياً رغم أنه يبدو مفعَّلاً
`ThrottlerModule.forRoot([{ ttl: 60000, limit: 120 }])` كان مسجَّلاً في `app.module.ts` منذ البداية — لكن **بلا أي حارس يطبّقه**. لا `APP_GUARD`، ولا `@UseGuards(ThrottlerGuard)` في أي متحكّم. أي أن كل نقطة في الـAPI، بما فيها `verify-otp` و`payouts/*`، كانت **بلا أي حدّ طلبات إطلاقاً** — التسجيل وحده لا يفعل شيئاً في NestJS.
@@ -84,6 +93,8 @@
- ✅ `HealthController` مُستثنى (`@SkipThrottle()`) — مراقبة التشغيل بلا بيانات حساسة.
- 🟡 **قرار وعي بالمخاطرة**: فكّرت في تتبّع بالمستخدم المصادَق لا بالـIP وحده (مهم لتطبيق موبايل — عناوين NAT عند مشغّلي الجوّال تجمع آلاف المستخدمين خلف IP واحد). التنفيذ يحتاج حارساً مخصَّصاً بحقن يدوي دقيق (`InjectThrottlerOptions`/`InjectThrottlerStorage`)، وخطأ فيه **يمنع إقلاع التطبيق كاملاً** — ولا بيئة هنا لتشغيل `NestFactory.create()` والتحقق قبل الدفع (اختبارات jest تُنشئ الخدمات يدوياً، فلا تكشف أخطاء DI للحرّاس العالميين). رجّحت الأمان: تُرك بالتتبّع الافتراضي (IP)، والفكرة موثّقة هنا لتُنفَّذ حين يمكن اختبارها فعلياً على السيرفر قبل الدفع.
**مقارنة بسيرو** (`core/Auth/RateLimiter.php`): سيرو يتتبّع بـ`IP:userId` (النمط الذي أجّلناه أعلاه) وله حدود مسمّاة لكل نوع (login 5/د · otp 3/5د · register 3/ساعة · api 120/د)، **وأهم ميزة فيه: fallback بملف مؤقّت عند تعطّل Redis بدل تمرير كل الطلبات** (fail-closed). عندنا **مسار OTP fail-closed أصلاً**: تخزين الرمز والعدّاد عبر Redis مباشرةً، فتعطّل Redis يرمي ويُفشل الطلب لا يمرّره. لكن حارس الطلبات العام (ThrottlerGuard) يستعمل ذاكرة داخلية لكل نسخة — لا يشارك بين النسخ. **بند مؤجَّل**: تخزين Throttler على Redis + fallback fail-closed عند الحاجة للتوسّع الأفقي الفعلي.
---
## المجموعة E — كشف الاحتيال (من `driver_ride_scam`) — ✅ منفَّذة