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:
co-authored by
Claude Opus 4.8
parent
c258a8c4b8
commit
8f6c46f0ac
+10
-2
@@ -42,14 +42,22 @@ MAPS_API_KEY_JO=
|
|||||||
MAPS_API_KEY_SY=
|
MAPS_API_KEY_SY=
|
||||||
MAPS_PLACES_API_KEY=
|
MAPS_PLACES_API_KEY=
|
||||||
|
|
||||||
# ---- OTP via Nabeh (WhatsApp) ----
|
# ---- OTP: توجيه متعدد المزوّدين حسب دولة المستأجر (docs/17 — D5) ----
|
||||||
# اتركه dev لتفعيل الرمز الثابت 1234 بلا إرسال؛ اجعله false للإرسال الحقيقي عبر Nabeh
|
# اتركه dev لتفعيل الرمز الثابت 1234 بلا إرسال؛ اجعله false للإرسال الحقيقي
|
||||||
OTP_DEV_MODE=true
|
OTP_DEV_MODE=true
|
||||||
|
OTP_SMS_TEMPLATE=Your Tripz code is {code}
|
||||||
|
|
||||||
|
# سوريا/الأردن — Nabeh (واتساب)
|
||||||
NABEH_BASE_URL=https://nabeh.intaleqapp.com
|
NABEH_BASE_URL=https://nabeh.intaleqapp.com
|
||||||
NABEH_EMAIL=
|
NABEH_EMAIL=
|
||||||
NABEH_PASSWORD=
|
NABEH_PASSWORD=
|
||||||
NABEH_OTP_TYPE=text
|
NABEH_OTP_TYPE=text
|
||||||
|
|
||||||
|
# مصر — Kazumi (SMS)، مع failover تلقائي إلى Nabeh لو فشل
|
||||||
|
SMS_USERNAME=
|
||||||
|
SMS_PASSWORD_EGYPT=
|
||||||
|
SMS_SENDER=
|
||||||
|
|
||||||
# ---- FCM push (اتركه فارغاً لتعطيل الإرسال) ----
|
# ---- FCM push (اتركه فارغاً لتعطيل الإرسال) ----
|
||||||
FCM_SERVER_KEY=
|
FCM_SERVER_KEY=
|
||||||
FCM_ENDPOINT=https://fcm.googleapis.com/fcm/send
|
FCM_ENDPOINT=https://fcm.googleapis.com/fcm/send
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { FraudModule } from './modules/fraud/fraud.module';
|
|||||||
import { ChatModule } from './modules/chat/chat.module';
|
import { ChatModule } from './modules/chat/chat.module';
|
||||||
import { RatingsModule } from './modules/ratings/ratings.module';
|
import { RatingsModule } from './modules/ratings/ratings.module';
|
||||||
import { NabehModule } from './integrations/nabeh/nabeh.module';
|
import { NabehModule } from './integrations/nabeh/nabeh.module';
|
||||||
|
import { OtpModule } from './integrations/otp/otp.module';
|
||||||
import { RideTypesModule } from './modules/ride-types/ride-types.module';
|
import { RideTypesModule } from './modules/ride-types/ride-types.module';
|
||||||
import { DispatchModule } from './modules/dispatch/dispatch.module';
|
import { DispatchModule } from './modules/dispatch/dispatch.module';
|
||||||
import { WalletModule } from './modules/wallet/wallet.module';
|
import { WalletModule } from './modules/wallet/wallet.module';
|
||||||
@@ -73,7 +74,8 @@ import { GeminiModule } from './integrations/gemini/gemini.module';
|
|||||||
SigningModule, // عالمي — توقيع HMAC للعمليات المالية
|
SigningModule, // عالمي — توقيع HMAC للعمليات المالية
|
||||||
PhoneModule, // عالمي — تطبيع أرقام الهاتف لكل دولة
|
PhoneModule, // عالمي — تطبيع أرقام الهاتف لكل دولة
|
||||||
DeviceModule, // عالمي — ربط الجلسة بالجهاز
|
DeviceModule, // عالمي — ربط الجلسة بالجهاز
|
||||||
NabehModule, // عالمي — إرسال OTP واتساب
|
NabehModule, // عالمي — إرسال OTP واتساب (سوريا/الأردن)
|
||||||
|
OtpModule, // عالمي — توجيه OTP متعدد المزوّدين حسب الدولة + failover
|
||||||
NotificationsModule, // عالمي — FCM
|
NotificationsModule, // عالمي — FCM
|
||||||
StorageModule, // عالمي — تخزين ملفات الوثائق
|
StorageModule, // عالمي — تخزين ملفات الوثائق
|
||||||
GeminiModule, // عالمي — رؤية AI (وثائق + وجه)
|
GeminiModule, // عالمي — رؤية AI (وثائق + وجه)
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ export default () => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Nabeh — إرسال OTP عبر واتساب.
|
// Nabeh — إرسال OTP عبر واتساب.
|
||||||
|
// توجيه OTP متعدد المزوّدين حسب الدولة (docs/17 — D5).
|
||||||
|
otp: {
|
||||||
|
// نصّ رسالة SMS (Kazumi/مصر). {code} يُستبدل بالرمز.
|
||||||
|
smsTemplate: process.env.OTP_SMS_TEMPLATE ?? 'Your Tripz code is {code}',
|
||||||
|
kazumi: {
|
||||||
|
username: process.env.SMS_USERNAME ?? '',
|
||||||
|
password: process.env.SMS_PASSWORD_EGYPT ?? '',
|
||||||
|
sender: process.env.SMS_SENDER ?? '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
nabeh: {
|
nabeh: {
|
||||||
baseUrl: process.env.NABEH_BASE_URL ?? 'https://nabeh.intaleqapp.com',
|
baseUrl: process.env.NABEH_BASE_URL ?? 'https://nabeh.intaleqapp.com',
|
||||||
email: process.env.NABEH_EMAIL ?? '',
|
email: process.env.NABEH_EMAIL ?? '',
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { OtpDispatcher } from './otp-dispatcher.service';
|
||||||
|
import { OtpProvider } from './otp-provider.interface';
|
||||||
|
|
||||||
|
function provider(name: string, result: boolean | (() => boolean)): OtpProvider {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
supports: () => true,
|
||||||
|
send: jest.fn(async () => (typeof result === 'function' ? result() : result)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OtpDispatcher — توجيه حسب الدولة + failover (docs/17 D5)', () => {
|
||||||
|
it('مصر تجرّب Kazumi أولاً', async () => {
|
||||||
|
const kazumi = provider('kazumi', true);
|
||||||
|
const nabeh = provider('nabeh', true);
|
||||||
|
const d = new OtpDispatcher([kazumi, nabeh]);
|
||||||
|
|
||||||
|
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
|
||||||
|
expect(kazumi.send).toHaveBeenCalledTimes(1);
|
||||||
|
expect(nabeh.send).not.toHaveBeenCalled(); // نجح الأول فلا failover
|
||||||
|
});
|
||||||
|
|
||||||
|
it('مصر: فشل Kazumi → يتحوّل إلى Nabeh', async () => {
|
||||||
|
const kazumi = provider('kazumi', false);
|
||||||
|
const nabeh = provider('nabeh', true);
|
||||||
|
const d = new OtpDispatcher([kazumi, nabeh]);
|
||||||
|
|
||||||
|
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
|
||||||
|
expect(kazumi.send).toHaveBeenCalledTimes(1);
|
||||||
|
expect(nabeh.send).toHaveBeenCalledTimes(1); // جرّب البديل
|
||||||
|
});
|
||||||
|
|
||||||
|
it('الأردن يستعمل Nabeh فقط — لا يلمس Kazumi', async () => {
|
||||||
|
const kazumi = provider('kazumi', true);
|
||||||
|
const nabeh = provider('nabeh', true);
|
||||||
|
const d = new OtpDispatcher([kazumi, nabeh]);
|
||||||
|
|
||||||
|
expect(await d.send('962790000000', '1234', { countryPack: 'jo' })).toBe(true);
|
||||||
|
expect(nabeh.send).toHaveBeenCalledTimes(1);
|
||||||
|
expect(kazumi.send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('سوريا تستعمل Nabeh', async () => {
|
||||||
|
const nabeh = provider('nabeh', true);
|
||||||
|
const d = new OtpDispatcher([nabeh]);
|
||||||
|
expect(await d.send('963944000000', '1234', { countryPack: 'sy' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('فشل السلسلة كاملة يرجع false — المُستدعي يُفشل الطلب', async () => {
|
||||||
|
const kazumi = provider('kazumi', false);
|
||||||
|
const nabeh = provider('nabeh', false);
|
||||||
|
const d = new OtpDispatcher([kazumi, nabeh]);
|
||||||
|
|
||||||
|
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(false);
|
||||||
|
expect(kazumi.send).toHaveBeenCalledTimes(1);
|
||||||
|
expect(nabeh.send).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('دولة غير معروفة تسقط لـNabeh (السلسلة الافتراضية)', async () => {
|
||||||
|
const nabeh = provider('nabeh', true);
|
||||||
|
const d = new OtpDispatcher([nabeh]);
|
||||||
|
expect(await d.send('491234567', '1234', { countryPack: 'de' })).toBe(true);
|
||||||
|
expect(nabeh.send).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('مزوّد مفقود من التسجيل لا يُسقط السلسلة — يتخطّاه', async () => {
|
||||||
|
// Kazumi غير مسجَّل، فمصر تسقط لـNabeh وحده.
|
||||||
|
const nabeh = provider('nabeh', true);
|
||||||
|
const d = new OtpDispatcher([nabeh]);
|
||||||
|
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
|
||||||
|
expect(nabeh.send).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { OTP_PROVIDERS, OtpProvider, OtpSendContext } from './otp-provider.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* سلسلة الـfailover لكل دولة (docs/17 — D5؛ نمط سيرو auth/otp/request.php).
|
||||||
|
* الأول أساسي، والبقية بدائل تُجرَّب بالترتيب. مصر: Kazumi SMS ثم Nabeh واتساب
|
||||||
|
* (نفس منطق سيرو). الأردن/سوريا: Nabeh فقط.
|
||||||
|
*/
|
||||||
|
const ROUTING: Record<string, string[]> = {
|
||||||
|
eg: ['kazumi', 'nabeh'],
|
||||||
|
jo: ['nabeh'],
|
||||||
|
sy: ['nabeh'],
|
||||||
|
};
|
||||||
|
const DEFAULT_CHAIN = ['nabeh'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* يوجّه إرسال OTP للمزوّد المناسب حسب دولة المستأجر، مع failover.
|
||||||
|
*
|
||||||
|
* السبب في وجوده: خدمة واحدة تُستدعى من كل مكان يحتاج إرسال رمز (تسجيل دخول،
|
||||||
|
* تحقّق سحب لاحقاً) بلا أن يعرف المُستدعي أي مزوّد لأي دولة — تُضاف دولة أو
|
||||||
|
* مزوّد جديد هنا وحده.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OtpDispatcher {
|
||||||
|
private readonly logger = new Logger('OtpDispatcher');
|
||||||
|
private readonly byName = new Map<string, OtpProvider>();
|
||||||
|
|
||||||
|
constructor(@Inject(OTP_PROVIDERS) providers: OtpProvider[]) {
|
||||||
|
for (const p of providers) this.byName.set(p.name, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* يرسل الرمز عبر أول مزوّد ينجح في سلسلة الدولة.
|
||||||
|
* يرجع `true` إن نجح أي مزوّد؛ `false` إن فشلت السلسلة كاملة (يقرّر المُستدعي
|
||||||
|
* حينها إفشال الطلب — طلبٌ بلا رمز يصل = مستخدم عالق).
|
||||||
|
*/
|
||||||
|
async send(phone: string, code: string, ctx: OtpSendContext): Promise<boolean> {
|
||||||
|
const chain = ROUTING[ctx.countryPack] ?? DEFAULT_CHAIN;
|
||||||
|
for (const name of chain) {
|
||||||
|
const provider = this.byName.get(name);
|
||||||
|
if (!provider) {
|
||||||
|
this.logger.warn(`provider "${name}" not registered — skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ok = await provider.send(phone, code, ctx);
|
||||||
|
if (ok) {
|
||||||
|
this.logger.log(`sent via ${name} (country=${ctx.countryPack})`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
this.logger.warn(`${name} failed for ${ctx.countryPack} — trying next in chain`);
|
||||||
|
}
|
||||||
|
this.logger.error(`all providers failed for country=${ctx.countryPack}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* مزوّد إرسال OTP واحد (docs/17 — D5؛ نمط سيرو auth/otp/providers.php).
|
||||||
|
*
|
||||||
|
* كل مزوّد يرسل رمزاً **نولّده نحن** (لا يولّده المزوّد) — فالرمز يبقى مصدره
|
||||||
|
* السيرفر ومخزَّناً في Redis، والمزوّد قناة توصيل فقط. هذا يختلف عن مزوّد
|
||||||
|
* Intaleq عند سيرو الذي يولّد الرمز بنفسه ويرجعه — استبعدناه عمداً حفاظاً على
|
||||||
|
* مصدر واحد للرمز.
|
||||||
|
*/
|
||||||
|
export interface OtpSendContext {
|
||||||
|
/** دولة المستأجر (jo | sy | eg) — تحدّد المزوّد. */
|
||||||
|
countryPack: string;
|
||||||
|
/** passenger | driver | admin | service — لتخصيص نصّ الرسالة. */
|
||||||
|
userType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OtpProvider {
|
||||||
|
/** اسم قصير للتشخيص واللوغ. */
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
/** الدول التي يخدمها هذا المزوّد (رموز countryPack). */
|
||||||
|
supports(countryPack: string): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* يرسل الرمز. يرجع `true` عند النجاح المؤكَّد فقط — أي فشل أو استجابة
|
||||||
|
* غامضة = `false` كي يجرّب المُوزِّع المزوّد التالي في السلسلة.
|
||||||
|
* **لا يرمي**: الرمي يقطع سلسلة الـfailover.
|
||||||
|
*/
|
||||||
|
send(phone: string, code: string, ctx: OtpSendContext): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OTP_PROVIDERS = Symbol('OTP_PROVIDERS');
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { NabehModule } from '../nabeh/nabeh.module';
|
||||||
|
import { OtpDispatcher } from './otp-dispatcher.service';
|
||||||
|
import { OTP_PROVIDERS } from './otp-provider.interface';
|
||||||
|
import { NabehOtpProvider } from './providers/nabeh.provider';
|
||||||
|
import { KazumiSmsProvider } from './providers/kazumi.provider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* توجيه OTP متعدد المزوّدين (docs/17 — D5). عالمي: `OtpDispatcher` يُستدعى من
|
||||||
|
* كل مسار يرسل رمزاً. لإضافة مزوّد: أضِف موفّره هنا وسجّله في مصفوفة
|
||||||
|
* `OTP_PROVIDERS` وأضِف اسمه لسلسلة دولته في `otp-dispatcher.service.ts`.
|
||||||
|
*/
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [NabehModule],
|
||||||
|
providers: [
|
||||||
|
NabehOtpProvider,
|
||||||
|
KazumiSmsProvider,
|
||||||
|
{
|
||||||
|
provide: OTP_PROVIDERS,
|
||||||
|
useFactory: (nabeh: NabehOtpProvider, kazumi: KazumiSmsProvider) => [nabeh, kazumi],
|
||||||
|
inject: [NabehOtpProvider, KazumiSmsProvider],
|
||||||
|
},
|
||||||
|
OtpDispatcher,
|
||||||
|
],
|
||||||
|
exports: [OtpDispatcher],
|
||||||
|
})
|
||||||
|
export class OtpModule {}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { OtpProvider, OtpSendContext } from '../otp-provider.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مزوّد Kazumi (SMS) — مصر (docs/17 — D5؛ من سيرو auth/otp/providers.php).
|
||||||
|
*
|
||||||
|
* لماذا SMS لا واتساب في مصر: انتشار واتساب أقل هناك، وSMS أضمن وصولاً.
|
||||||
|
* لو فشل، يتولّى المُوزِّع الرجوع لمزوّد آخر (Nabeh واتساب) في السلسلة.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class KazumiSmsProvider implements OtpProvider {
|
||||||
|
readonly name = 'kazumi';
|
||||||
|
private readonly logger = new Logger('KazumiSms');
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
|
supports(countryPack: string): boolean {
|
||||||
|
return countryPack === 'eg';
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(phone: string, code: string, _ctx: OtpSendContext): Promise<boolean> {
|
||||||
|
const username = this.config.get<string>('otp.kazumi.username');
|
||||||
|
const password = this.config.get<string>('otp.kazumi.password');
|
||||||
|
const sender = this.config.get<string>('otp.kazumi.sender');
|
||||||
|
if (!username || !password || !sender) {
|
||||||
|
this.logger.warn('missing Kazumi credentials — skipping (failover will handle)');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://sms.kazumi.me/api/sms/send-sms', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
language: 'e',
|
||||||
|
sender,
|
||||||
|
receiver: phone,
|
||||||
|
message: this.config.get<string>('otp.smsTemplate')?.replace('{code}', code)
|
||||||
|
?? `Your Tripz code is ${code}`,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
this.logger.warn(`HTTP ${res.status}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const json: any = await res.json().catch(() => null);
|
||||||
|
// Kazumi يرجع { message: 'Success' } عند النجاح.
|
||||||
|
return json?.message === 'Success';
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`send failed: ${e?.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { NabehService } from '../../nabeh/nabeh.service';
|
||||||
|
import { OtpProvider, OtpSendContext } from '../otp-provider.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مزوّد Nabeh (واتساب) — سوريا والأردن (docs/17 — D5).
|
||||||
|
* غلاف رقيق حول `NabehService` القائم يحوّل الرمي إلى `false` ليعمل ضمن سلسلة
|
||||||
|
* الـfailover.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class NabehOtpProvider implements OtpProvider {
|
||||||
|
readonly name = 'nabeh';
|
||||||
|
private readonly logger = new Logger('NabehOtp');
|
||||||
|
|
||||||
|
constructor(private readonly nabeh: NabehService) {}
|
||||||
|
|
||||||
|
supports(countryPack: string): boolean {
|
||||||
|
return countryPack === 'jo' || countryPack === 'sy';
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(phone: string, code: string, _ctx: OtpSendContext): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.nabeh.sendOtp(phone, code);
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`send failed: ${e?.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
@@ -8,7 +14,7 @@ import { UsersService } from '../users/users.service';
|
|||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
import { TenantsService } from '../tenants/tenants.service';
|
import { TenantsService } from '../tenants/tenants.service';
|
||||||
import { Tenant } from '../../database/entities/tenant.entity';
|
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 { PhoneService } from '../../common/phone/phone.service';
|
||||||
import { DeviceService } from '../../common/device/device.service';
|
import { DeviceService } from '../../common/device/device.service';
|
||||||
|
|
||||||
@@ -21,7 +27,7 @@ export class AuthService {
|
|||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private tenantsService: TenantsService,
|
private tenantsService: TenantsService,
|
||||||
private nabeh: NabehService,
|
private readonly otp: OtpDispatcher,
|
||||||
private readonly signing: SigningService,
|
private readonly signing: SigningService,
|
||||||
private readonly phones: PhoneService,
|
private readonly phones: PhoneService,
|
||||||
private readonly device: DeviceService,
|
private readonly device: DeviceService,
|
||||||
@@ -76,9 +82,15 @@ export class AuthService {
|
|||||||
return { success: true, message: 'OTP sent (dev)', dev_code: code };
|
return { success: true, message: 'OTP sent (dev)', dev_code: code };
|
||||||
}
|
}
|
||||||
|
|
||||||
// إرسال حقيقي عبر واتساب (Nabeh)
|
// إرسال حقيقي — المُوزِّع يختار المزوّد حسب دولة المستأجر مع failover
|
||||||
await this.nabeh.sendOtp(this.phones.toWhatsApp(canonical), code);
|
// (docs/17 — D5). فشل السلسلة كاملة = لا رمز يصل، فنُفشل الطلب صراحةً.
|
||||||
return { success: true, message: 'OTP sent via WhatsApp' };
|
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) {
|
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 { PayoutsController } from './payouts.controller';
|
||||||
import { WalletModule } from '../wallet/wallet.module';
|
import { WalletModule } from '../wallet/wallet.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { TenantsModule } from '../tenants/tenants.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
// NabehModule و AuditModule عالميان.
|
// OtpModule و AuditModule عالميان.
|
||||||
imports: [TypeOrmModule.forFeature([Payment, Payout]), WalletModule, UsersModule],
|
imports: [TypeOrmModule.forFeature([Payment, Payout]), WalletModule, UsersModule, TenantsModule],
|
||||||
controllers: [PaymentsController, PayoutsController],
|
controllers: [PaymentsController, PayoutsController],
|
||||||
providers: [PaymentsService, PayoutsService],
|
providers: [PaymentsService, PayoutsService],
|
||||||
exports: [PaymentsService, PayoutsService],
|
exports: [PaymentsService, PayoutsService],
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import { REDIS } from '../../common/redis/redis.module';
|
|||||||
import { Payout } from './entities/payout.entity';
|
import { Payout } from './entities/payout.entity';
|
||||||
import { WalletService } from '../wallet/wallet.service';
|
import { WalletService } from '../wallet/wallet.service';
|
||||||
import { UsersService } from '../users/users.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';
|
import { AuditService } from '../../common/audit/audit.service';
|
||||||
|
|
||||||
export interface PayoutRequestDto {
|
export interface PayoutRequestDto {
|
||||||
@@ -52,7 +53,8 @@ export class PayoutsService {
|
|||||||
@Inject(REDIS) private readonly redis: Redis,
|
@Inject(REDIS) private readonly redis: Redis,
|
||||||
private readonly wallet: WalletService,
|
private readonly wallet: WalletService,
|
||||||
private readonly users: UsersService,
|
private readonly users: UsersService,
|
||||||
private readonly nabeh: NabehService,
|
private readonly tenants: TenantsService,
|
||||||
|
private readonly otp: OtpDispatcher,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
@@ -253,8 +255,14 @@ export class PayoutsService {
|
|||||||
}
|
}
|
||||||
const user = await this.users.findById(tenantId, driverUserId);
|
const user = await this.users.findById(tenantId, driverUserId);
|
||||||
if (!user?.phone) throw new BadRequestException('no phone on file');
|
if (!user?.phone) throw new BadRequestException('no phone on file');
|
||||||
// فشل الإرسال يُفشل الطلب: طلبٌ بلا رمز يصل = سائق عالق بلا سبيل للتأكيد.
|
// نفس مُوزِّع OTP الموحّد المستعمل في تسجيل الدخول (docs/17 — D5): يوجّه
|
||||||
await this.nabeh.sendOtp(user.phone, code);
|
// حسب دولة المستأجر ويطبّق 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;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,15 @@
|
|||||||
| D2 | **بصمة الجهاز** | 🟡 **مبنيّة ومطفأة** (`AUTH_REQUIRE_DEVICE_BINDING=false`) حتى يرسل فلاتر `x-device-id`. **مُنفَّذة داخل `JwtStrategy` نفسها لا كحارس منفصل** — يُفرض على كل نقطة محميّة تلقائياً، فلا نقطة منسيّة. توكن الدخول يحمل `hash(deviceId)` لا القيمة الخام؛ توكن مسروق من جهاز آخر يُرفض بمجرد تفعيل العلم. |
|
| D2 | **بصمة الجهاز** | 🟡 **مبنيّة ومطفأة** (`AUTH_REQUIRE_DEVICE_BINDING=false`) حتى يرسل فلاتر `x-device-id`. **مُنفَّذة داخل `JwtStrategy` نفسها لا كحارس منفصل** — يُفرض على كل نقطة محميّة تلقائياً، فلا نقطة منسيّة. توكن الدخول يحمل `hash(deviceId)` لا القيمة الخام؛ توكن مسروق من جهاز آخر يُرفض بمجرد تفعيل العلم. |
|
||||||
| D3 | **HMAC للعمليات الحساسة** | ✅ **منفَّذ ضمن I6** — `SigningService`/`SignatureGuard`، نفس البند لا تكرار. |
|
| D3 | **HMAC للعمليات الحساسة** | ✅ **منفَّذ ضمن I6** — `SigningService`/`SignatureGuard`، نفس البند لا تكرار. |
|
||||||
| D4 | **حدّ الطلبات (rate limiting)** | ✅ **إصلاح ثغرة قائمة** — راجع أدناه. |
|
| 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 — حدّ الطلبات: كان مُعطَّلاً كلياً رغم أنه يبدو مفعَّلاً
|
### D4 — حدّ الطلبات: كان مُعطَّلاً كلياً رغم أنه يبدو مفعَّلاً
|
||||||
`ThrottlerModule.forRoot([{ ttl: 60000, limit: 120 }])` كان مسجَّلاً في `app.module.ts` منذ البداية — لكن **بلا أي حارس يطبّقه**. لا `APP_GUARD`، ولا `@UseGuards(ThrottlerGuard)` في أي متحكّم. أي أن كل نقطة في الـAPI، بما فيها `verify-otp` و`payouts/*`، كانت **بلا أي حدّ طلبات إطلاقاً** — التسجيل وحده لا يفعل شيئاً في NestJS.
|
`ThrottlerModule.forRoot([{ ttl: 60000, limit: 120 }])` كان مسجَّلاً في `app.module.ts` منذ البداية — لكن **بلا أي حارس يطبّقه**. لا `APP_GUARD`، ولا `@UseGuards(ThrottlerGuard)` في أي متحكّم. أي أن كل نقطة في الـAPI، بما فيها `verify-otp` و`payouts/*`، كانت **بلا أي حدّ طلبات إطلاقاً** — التسجيل وحده لا يفعل شيئاً في NestJS.
|
||||||
@@ -84,6 +93,8 @@
|
|||||||
- ✅ `HealthController` مُستثنى (`@SkipThrottle()`) — مراقبة التشغيل بلا بيانات حساسة.
|
- ✅ `HealthController` مُستثنى (`@SkipThrottle()`) — مراقبة التشغيل بلا بيانات حساسة.
|
||||||
- 🟡 **قرار وعي بالمخاطرة**: فكّرت في تتبّع بالمستخدم المصادَق لا بالـIP وحده (مهم لتطبيق موبايل — عناوين NAT عند مشغّلي الجوّال تجمع آلاف المستخدمين خلف IP واحد). التنفيذ يحتاج حارساً مخصَّصاً بحقن يدوي دقيق (`InjectThrottlerOptions`/`InjectThrottlerStorage`)، وخطأ فيه **يمنع إقلاع التطبيق كاملاً** — ولا بيئة هنا لتشغيل `NestFactory.create()` والتحقق قبل الدفع (اختبارات jest تُنشئ الخدمات يدوياً، فلا تكشف أخطاء DI للحرّاس العالميين). رجّحت الأمان: تُرك بالتتبّع الافتراضي (IP)، والفكرة موثّقة هنا لتُنفَّذ حين يمكن اختبارها فعلياً على السيرفر قبل الدفع.
|
- 🟡 **قرار وعي بالمخاطرة**: فكّرت في تتبّع بالمستخدم المصادَق لا بالـ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`) — ✅ منفَّذة
|
## المجموعة E — كشف الاحتيال (من `driver_ride_scam`) — ✅ منفَّذة
|
||||||
|
|||||||
Reference in New Issue
Block a user