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
+18 -6
View File
@@ -1,4 +1,10 @@
import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import {
Inject,
Injectable,
Logger,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
@@ -8,7 +14,7 @@ import { UsersService } from '../users/users.service';
import { User } from '../users/entities/user.entity';
import { TenantsService } from '../tenants/tenants.service';
import { Tenant } from '../../database/entities/tenant.entity';
import { NabehService } from '../../integrations/nabeh/nabeh.service';
import { OtpDispatcher } from '../../integrations/otp/otp-dispatcher.service';
import { PhoneService } from '../../common/phone/phone.service';
import { DeviceService } from '../../common/device/device.service';
@@ -21,7 +27,7 @@ export class AuthService {
private jwtService: JwtService,
private config: ConfigService,
private tenantsService: TenantsService,
private nabeh: NabehService,
private readonly otp: OtpDispatcher,
private readonly signing: SigningService,
private readonly phones: PhoneService,
private readonly device: DeviceService,
@@ -76,9 +82,15 @@ export class AuthService {
return { success: true, message: 'OTP sent (dev)', dev_code: code };
}
// إرسال حقيقي عبر واتساب (Nabeh)
await this.nabeh.sendOtp(this.phones.toWhatsApp(canonical), code);
return { success: true, message: 'OTP sent via WhatsApp' };
// إرسال حقيقي — المُوزِّع يختار المزوّد حسب دولة المستأجر مع failover
// (docs/17 — D5). فشل السلسلة كاملة = لا رمز يصل، فنُفشل الطلب صراحةً.
const sent = await this.otp.send(this.phones.toWhatsApp(canonical), code, {
countryPack: tenant.countryPack,
});
if (!sent) {
throw new ServiceUnavailableException('Failed to send verification code — please try again');
}
return { success: true, message: 'OTP sent' };
}
async verifyOtp(tenantSlug: string, phone: string, code: string, deviceId?: string) {
@@ -8,10 +8,11 @@ import { PaymentsController } from './payments.controller';
import { PayoutsController } from './payouts.controller';
import { WalletModule } from '../wallet/wallet.module';
import { UsersModule } from '../users/users.module';
import { TenantsModule } from '../tenants/tenants.module';
@Module({
// NabehModule و AuditModule عالميان.
imports: [TypeOrmModule.forFeature([Payment, Payout]), WalletModule, UsersModule],
// OtpModule و AuditModule عالميان.
imports: [TypeOrmModule.forFeature([Payment, Payout]), WalletModule, UsersModule, TenantsModule],
controllers: [PaymentsController, PayoutsController],
providers: [PaymentsService, PayoutsService],
exports: [PaymentsService, PayoutsService],
@@ -16,7 +16,8 @@ import { REDIS } from '../../common/redis/redis.module';
import { Payout } from './entities/payout.entity';
import { WalletService } from '../wallet/wallet.service';
import { UsersService } from '../users/users.service';
import { NabehService } from '../../integrations/nabeh/nabeh.service';
import { OtpDispatcher } from '../../integrations/otp/otp-dispatcher.service';
import { TenantsService } from '../tenants/tenants.service';
import { AuditService } from '../../common/audit/audit.service';
export interface PayoutRequestDto {
@@ -52,7 +53,8 @@ export class PayoutsService {
@Inject(REDIS) private readonly redis: Redis,
private readonly wallet: WalletService,
private readonly users: UsersService,
private readonly nabeh: NabehService,
private readonly tenants: TenantsService,
private readonly otp: OtpDispatcher,
private readonly audit: AuditService,
private readonly config: ConfigService,
) {}
@@ -253,8 +255,14 @@ export class PayoutsService {
}
const user = await this.users.findById(tenantId, driverUserId);
if (!user?.phone) throw new BadRequestException('no phone on file');
// فشل الإرسال يُفشل الطلب: طلبٌ بلا رمز يصل = سائق عالق بلا سبيل للتأكيد.
await this.nabeh.sendOtp(user.phone, code);
// نفس مُوزِّع OTP الموحّد المستعمل في تسجيل الدخول (docs/17 — D5): يوجّه
// حسب دولة المستأجر ويطبّق failover. فشل السلسلة = سائق عالق بلا رمز.
const tenant = await this.tenants.resolve(tenantId);
const sent = await this.otp.send(user.phone, code, {
countryPack: tenant?.countryPack ?? 'jo',
userType: 'driver',
});
if (!sent) throw new BadRequestException('failed to send verification code');
return code;
}