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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user