From 160575472243e40f8e734d7ae33efde22326ce9a Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 17 Jul 2026 16:22:00 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D8=A8=D8=A7=D9=82=D9=8A=20=D8=A7=D9=84?= =?UTF-8?q?=D9=85=D8=AC=D9=85=D9=88=D8=B9=D8=A9=20I=20=E2=80=94=20=D8=A3?= =?UTF-8?q?=D9=85=D8=A7=D9=86=20=D8=A7=D9=84=D8=B3=D8=AD=D8=A8=20(OTP=20+?= =?UTF-8?q?=20=D8=A8=D8=B5=D9=85=D8=A9=20+=20HMAC=20+=20=D8=AA=D8=AF=D9=82?= =?UTF-8?q?=D9=8A=D9=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I4 — OTP على السحب عبر نبيه. تدفّق خطوتين، وقاعدته: **لا يتحرك مال قبل إثبات الهوية**: - POST /payouts/request → يرسل رمزاً، حالة pending_otp، **بلا خصم** - POST /payouts/:id/confirm → يتحقق ثم يحجز ذرّياً (خصم I1) - حدّ 5 محاولات (رمز 4 خانات يُخمَّن في دقائق بلا حدّ)، الرمز يُستهلك مرة - complete يرفض طلباً بلا otp_verified_at (حارس ضد تخطّي التأكيد) - fail لا يردّ مالاً لطلب pending_otp — لم يُخصم منه شيء، وردّه يخلق مالاً I5 — biometric_method/at + device_id + request_ip على السحب. أثرٌ للتحقيق لا مصادقة: العميل يستطيع ادّعاءها، والمصادقة الحقيقية JWT + رمز واتساب. I6 — HMAC **مبنيّ ومطفأ** (PAYMENTS_REQUIRE_SIGNATURE=false) حتى يوقّع فلاتر؛ تفعيله الآن يقطع كل سحب. مفتاح **لكل جلسة** يُصدره الدخول لا سرّ ثابت في التطبيق (الثابت يُستخرج بالهندسة العكسية فيصير التوقيع مسرحية). يوقّع timestamp.METHOD.path.body بنافذة 5 دقائق؛ rawBody مفعّل في main. I7 — tripz_audit_log append-only: من·ماذا·متى·أي IP وجهاز. لا يرمي أبداً — فشل التدقيق يجب ألّا يُسقط عمليةً مالية نجحت. I2 أُلغيت الحاجة إليها: إيراد المنصة = الشحن، و credit_txns دفتره فعلاً؛ محفظة ثانية = دفتر مزدوج يحتاج مطابقة. الباقي تقرير لا محفظة. I3/I8 مؤجَّلتان بوعي (توثيق في docs/17). wallet-race-test.mjs حُدِّث: السباق انتقل من request إلى confirm، وأُضيف محكّ أن الطلب وحده لا يمسّ الرصيد. هجرة: PayoutSecurityAndAudit (تعتبر السحوبات القائمة مُتحقَّقة وإلا رفض complete صرفها للأبد). Co-Authored-By: Claude Opus 4.8 --- backend/scripts/wallet-race-test.mjs | 37 ++- backend/src/app.module.ts | 4 + backend/src/common/audit/audit-log.entity.ts | 56 ++++ backend/src/common/audit/audit.module.ts | 12 + backend/src/common/audit/audit.service.ts | 58 ++++ backend/src/common/signing/signature.guard.ts | 70 +++++ backend/src/common/signing/signing.module.ts | 10 + .../common/signing/signing.service.spec.ts | 119 ++++++++ backend/src/common/signing/signing.service.ts | 79 +++++ backend/src/config/configuration.ts | 6 + .../1721870000000-PayoutSecurityAndAudit.ts | 63 ++++ backend/src/main.ts | 13 +- backend/src/modules/auth/auth.service.ts | 12 +- .../payments/entities/payout.entity.ts | 29 +- .../src/modules/payments/payments.module.ts | 4 +- .../modules/payments/payouts.controller.ts | 74 ++++- .../src/modules/payments/payouts.service.ts | 272 ++++++++++++++++-- docs/17-backend-backlog.md | 21 +- 18 files changed, 879 insertions(+), 60 deletions(-) create mode 100644 backend/src/common/audit/audit-log.entity.ts create mode 100644 backend/src/common/audit/audit.module.ts create mode 100644 backend/src/common/audit/audit.service.ts create mode 100644 backend/src/common/signing/signature.guard.ts create mode 100644 backend/src/common/signing/signing.module.ts create mode 100644 backend/src/common/signing/signing.service.spec.ts create mode 100644 backend/src/common/signing/signing.service.ts create mode 100644 backend/src/database/migrations/1721870000000-PayoutSecurityAndAudit.ts diff --git a/backend/scripts/wallet-race-test.mjs b/backend/scripts/wallet-race-test.mjs index f332c14..993c41a 100644 --- a/backend/scripts/wallet-race-test.mjs +++ b/backend/scripts/wallet-race-test.mjs @@ -52,16 +52,35 @@ async function main() { process.exit(1); } - // كل الطلبات تنطلق دفعة واحدة على نفس الصف. - console.log(`إطلاق ${ATTEMPTS} خصم متزامن × ${AMOUNT} (يكفي ${expectedSuccesses} فقط)…`); + // تدفّق السحب صار خطوتين (docs/17 — I4): الطلب يرسل رمزاً ولا يخصم شيئاً، + // والخصم يقع عند التأكيد. فالسباق هنا على `confirm` لا على `request`. + // يعتمد dev_code الذي ترجعه النقطة في وضع التطوير (OTP_DEV_MODE=true). + console.log(`تحضير ${ATTEMPTS} طلب سحب (بلا خصم)…`); + const requested = await Promise.all( + Array.from({ length: ATTEMPTS }, () => + api('POST', '/payouts/request', t, { amount: AMOUNT, channel: 'cliq' }).catch(() => null), + ), + ); + const pending = requested.filter((r) => r?.ok && r.body?.payout?.id && r.body?.dev_code); + if (pending.length !== ATTEMPTS) { + console.log(`⚠️ حُضّر ${pending.length}/${ATTEMPTS} فقط.`); + if (pending.length === 0) { + console.log('لا شيء لتأكيده — تأكّد أن OTP_DEV_MODE=true (يُرجع dev_code).'); + process.exit(1); + } + } + + // فحص وسيط: الطلب وحده يجب ألّا يمسّ الرصيد إطلاقاً. + const mid = await api('GET', '/wallet', t); + const midBalance = Number(mid.body.balance); + + console.log(`إطلاق ${pending.length} تأكيد متزامن × ${AMOUNT} (يكفي ${expectedSuccesses} فقط)…`); const t0 = Date.now(); const results = await Promise.all( - Array.from({ length: ATTEMPTS }, () => - api('POST', '/payouts/request', t, { amount: AMOUNT, channel: 'cliq' }).catch((e) => ({ - ok: false, - status: 0, - body: { message: String(e) }, - })), + pending.map((r) => + api('POST', `/payouts/${r.body.payout.id}/confirm`, t, { + code: String(r.body.dev_code), + }).catch((e) => ({ ok: false, status: 0, body: { message: String(e) } })), ), ); const elapsed = Date.now() - t0; @@ -80,8 +99,8 @@ async function main() { console.log(`الزمن : ${elapsed}ms`); console.log(''); - // المحكّات الثلاثة const checks = [ + ['الطلب وحده لا يمسّ الرصيد (لا مال قبل إثبات الهوية)', midBalance === startBalance], ['الرصيد لم يصبح سالباً', endBalance >= 0], ['عدد النجاحات = ما يسمح به الرصيد', ok === expectedSuccesses], ['المخصوم = النجاحات × المبلغ (لا مال ضائع/مخلوق)', Math.abs(spent - ok * AMOUNT) < 1e-6], diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2e569f9..fa99b5f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -9,6 +9,8 @@ import { I18nModule } from './common/i18n/i18n.module'; import { CacheModule } from './common/cache/cache.module'; import { EntitlementsModule } from './common/entitlements/entitlements.module'; import { PlatformModule } from './common/platform/platform.module'; +import { AuditModule } from './common/audit/audit.module'; +import { SigningModule } from './common/signing/signing.module'; import { SeedModule } from './common/seed/seed.module'; import { HealthModule } from './modules/health/health.module'; import { TenantsModule } from './modules/tenants/tenants.module'; @@ -64,6 +66,8 @@ import { GeminiModule } from './integrations/gemini/gemini.module'; CacheModule, // عالمي — كاش-جانبي فوق Redis (خط أول قبل القاعدة) EntitlementsModule, // عالمي — استحقاقات الاشتراك + FeatureGuard PlatformModule, // عالمي — حارس السوبر-أدمن (x-platform-secret) + AuditModule, // عالمي — سجل التدقيق المالي + SigningModule, // عالمي — توقيع HMAC للعمليات المالية NabehModule, // عالمي — إرسال OTP واتساب NotificationsModule, // عالمي — FCM StorageModule, // عالمي — تخزين ملفات الوثائق diff --git a/backend/src/common/audit/audit-log.entity.ts b/backend/src/common/audit/audit-log.entity.ts new file mode 100644 index 0000000..f8f2163 --- /dev/null +++ b/backend/src/common/audit/audit-log.entity.ts @@ -0,0 +1,56 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** + * سجل التدقيق المالي (docs/17 — I7؛ مكافئ `admin_audit_log` عند سيرو). + * الجدول: tripz_audit_log. **append-only** — لا تحديث ولا حذف. + * + * يجيب عن سؤال واحد عند كل نزاع: **من فعل ماذا ومتى ومن أين؟** + */ +@Entity('audit_log') +@Index(['tenant_id', 'created_at']) +@Index(['tenant_id', 'subject_type', 'subject_id']) +export class AuditLog { + @PrimaryGeneratedColumn('increment') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + /** من نفّذ الفعل — قد يكون السائق نفسه أو أدمن أو `null` للنظام. */ + @Column({ type: 'uuid', nullable: true }) + actor_user_id: string | null; + + @Column({ type: 'varchar', nullable: true }) + actor_role: string | null; + + /** payout.request · payout.confirm · payout.complete · payout.fail · credit.topup … */ + @Column() + action: string; + + @Column({ type: 'varchar', nullable: true }) + subject_type: string | null; // payout | credit | wallet + + @Column({ type: 'varchar', nullable: true }) + subject_id: string | null; + + @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) + amount: number | null; + + @Column({ type: 'varchar', nullable: true }) + currency: string | null; + + @Column({ type: 'jsonb', default: {} }) + meta: Record; + + @Column({ type: 'varchar', nullable: true }) + ip: string | null; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/common/audit/audit.module.ts b/backend/src/common/audit/audit.module.ts new file mode 100644 index 0000000..8831b2a --- /dev/null +++ b/backend/src/common/audit/audit.module.ts @@ -0,0 +1,12 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditLog } from './audit-log.entity'; +import { AuditService } from './audit.service'; + +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([AuditLog])], + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/backend/src/common/audit/audit.service.ts b/backend/src/common/audit/audit.service.ts new file mode 100644 index 0000000..41c2702 --- /dev/null +++ b/backend/src/common/audit/audit.service.ts @@ -0,0 +1,58 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditLog } from './audit-log.entity'; + +export interface AuditEntry { + tenantId: string; + action: string; + actorUserId?: string | null; + actorRole?: string | null; + subjectType?: string; + subjectId?: string; + amount?: number | null; + currency?: string | null; + meta?: Record; + ip?: string | null; +} + +/** سجل التدقيق المالي (docs/17 — I7). */ +@Injectable() +export class AuditService { + private readonly logger = new Logger('Audit'); + + constructor(@InjectRepository(AuditLog) private readonly repo: Repository) {} + + /** + * يسجّل فعلاً مالياً. + * + * **لا يرمي أبداً**: فشل التدقيق يجب ألّا يُسقط عمليةً ماليةً نجحت — وإلا + * صار السجل نقطة فشل بدل أن يكون شبكة أمان. الفشل يُسجَّل في اللوغ ليُرى. + */ + async record(entry: AuditEntry): Promise { + try { + await this.repo.insert({ + tenant_id: entry.tenantId, + actor_user_id: entry.actorUserId ?? null, + actor_role: entry.actorRole ?? null, + action: entry.action, + subject_type: entry.subjectType ?? null, + subject_id: entry.subjectId ?? null, + amount: entry.amount ?? null, + currency: entry.currency ?? null, + meta: entry.meta ?? {}, + ip: entry.ip ?? null, + }); + } catch (e: any) { + this.logger.error(`audit write failed [${entry.action}]: ${e?.message}`); + } + } + + /** سجلّ موضوع بعينه — للتحقيق في نزاع. */ + forSubject(tenantId: string, subjectType: string, subjectId: string) { + return this.repo.find({ + where: { tenant_id: tenantId, subject_type: subjectType, subject_id: subjectId }, + order: { created_at: 'ASC' }, + }); + } +} diff --git a/backend/src/common/signing/signature.guard.ts b/backend/src/common/signing/signature.guard.ts new file mode 100644 index 0000000..e7cf264 --- /dev/null +++ b/backend/src/common/signing/signature.guard.ts @@ -0,0 +1,70 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SigningService } from './signing.service'; +import { AuditService } from '../audit/audit.service'; + +/** + * يحرس العمليات المالية بتوقيع HMAC (docs/17 — I6). + * + * **مطفأ افتراضياً** (`PAYMENTS_REQUIRE_SIGNATURE=false`): التوقيع يحتاج فلاتر + * أن يوقّع أولاً، وتفعيله قبل ذلك يقطع كل سحب. يُفعَّل بمتغيّر بيئة فور جاهزية + * التطبيق — بلا نشر كود. + * + * ما يقدّمه فعلاً: الـAPI يعمل على **http** بلا TLS، فمن يلتقط الشبكة يسرق + * التوكن. التوقيع بمفتاح جلسة (ليس في التوكن ولا في الـAPK) يجعل التوكن + * المسروق وحده غير كافٍ لتوقيع سحب، ويمنع تبديل المبلغ في الطريق. + * **هذا ترقيع لغياب TLS لا بديل عنه** — راجع docs/17. + */ +@Injectable() +export class SignatureGuard implements CanActivate { + private readonly logger = new Logger('SignatureGuard'); + + constructor( + private readonly signing: SigningService, + private readonly config: ConfigService, + private readonly audit: AuditService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + if (!this.config.get('payments.requireSignature')) return true; + + const req = context.switchToHttp().getRequest(); + const user = req.user; + if (!user?.userId || !user?.tenantId) throw new UnauthorizedException('unsigned_request'); + + const signature = req.headers?.['x-signature']; + const timestamp = req.headers?.['x-timestamp']; + if (typeof signature !== 'string' || typeof timestamp !== 'string') { + throw new UnauthorizedException('unsigned_request'); + } + + const reason = await this.signing.verify( + user.tenantId, + user.userId, + signature, + timestamp, + req.method, + req.originalUrl?.split('?')[0] ?? req.url, + // الجسم الخام كما وصل؛ إعادة تسلسله قد تُغيّر ترتيب المفاتيح فيفشل توقيع سليم. + req.rawBody?.toString() ?? JSON.stringify(req.body ?? {}), + ); + if (reason) { + await this.audit.record({ + tenantId: user.tenantId, + actorUserId: user.userId, + action: 'signature.rejected', + ip: req.ip ?? null, + meta: { reason, path: req.url }, + }); + // رسالة واحدة لكل الأسباب — لا نُعلّم المهاجم أين أخطأ. + throw new UnauthorizedException('unsigned_request'); + } + return true; + } +} diff --git a/backend/src/common/signing/signing.module.ts b/backend/src/common/signing/signing.module.ts new file mode 100644 index 0000000..2b5826a --- /dev/null +++ b/backend/src/common/signing/signing.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; +import { SigningService } from './signing.service'; +import { SignatureGuard } from './signature.guard'; + +@Global() +@Module({ + providers: [SigningService, SignatureGuard], + exports: [SigningService, SignatureGuard], +}) +export class SigningModule {} diff --git a/backend/src/common/signing/signing.service.spec.ts b/backend/src/common/signing/signing.service.spec.ts new file mode 100644 index 0000000..d0a4329 --- /dev/null +++ b/backend/src/common/signing/signing.service.spec.ts @@ -0,0 +1,119 @@ +import RedisMock from 'ioredis-mock'; +import { SigningService } from './signing.service'; + +const TENANT = 't1'; +const USER = 'u1'; +const BODY = '{"amount":50,"channel":"cliq"}'; + +describe('SigningService — توقيع العمليات المالية (docs/17 I6)', () => { + let redis: any; + let signing: SigningService; + + const sign = (secret: string, ts: string, method = 'POST', path = '/api/payouts/request', body = BODY) => + SigningService.sign(secret, SigningService.payload(ts, method, path, body)); + + const now = () => String(Math.floor(Date.now() / 1000)); + + beforeEach(async () => { + redis = new RedisMock({ keyPrefix: 'tripz:' }); + await redis.flushall(); + signing = new SigningService(redis); + }); + + it('توقيع صحيح يمرّ', async () => { + const secret = await signing.issue(TENANT, USER); + const ts = now(); + const sig = sign(secret, ts); + + expect(await signing.verify(TENANT, USER, sig, ts, 'POST', '/api/payouts/request', BODY)).toBeNull(); + }); + + it('تبديل المبلغ في الطريق يُكشف', async () => { + const secret = await signing.issue(TENANT, USER); + const ts = now(); + const sig = sign(secret, ts); // وُقّع على 50 + + const tampered = '{"amount":5000,"channel":"cliq"}'; + expect(await signing.verify(TENANT, USER, sig, ts, 'POST', '/api/payouts/request', tampered)).toBe( + 'bad_signature', + ); + }); + + it('توقيع نقطةٍ لا يصلح لنقطة أخرى', async () => { + const secret = await signing.issue(TENANT, USER); + const ts = now(); + const sig = sign(secret, ts, 'POST', '/api/payouts/request'); + + expect(await signing.verify(TENANT, USER, sig, ts, 'POST', '/api/payouts/x/confirm', BODY)).toBe( + 'bad_signature', + ); + }); + + it('طابع زمني قديم يُرفض (منع إعادة البثّ)', async () => { + const secret = await signing.issue(TENANT, USER); + const old = String(Math.floor(Date.now() / 1000) - 3600); + const sig = sign(secret, old); + + expect(await signing.verify(TENANT, USER, sig, old, 'POST', '/api/payouts/request', BODY)).toBe( + 'stale_timestamp', + ); + }); + + it('طابع زمني غير رقمي يُرفض بلا انهيار', async () => { + const secret = await signing.issue(TENANT, USER); + expect(await signing.verify(TENANT, USER, sign(secret, 'x'), 'x', 'POST', '/p', BODY)).toBe( + 'bad_timestamp', + ); + }); + + it('مفتاح مستخدم آخر لا يوقّع نيابةً عنه', async () => { + const secret = await signing.issue(TENANT, USER); + await signing.issue(TENANT, 'attacker'); + const ts = now(); + const sig = sign(secret, ts); + + expect(await signing.verify(TENANT, 'attacker', sig, ts, 'POST', '/api/payouts/request', BODY)).toBe( + 'bad_signature', + ); + }); + + it('المستأجرون معزولون', async () => { + const secret = await signing.issue(TENANT, USER); + const ts = now(); + expect(await signing.verify('t2', USER, sign(secret, ts), ts, 'POST', '/p', BODY)).toBe( + 'no_signing_key', + ); + }); + + it('بلا مفتاح (لم يسجّل دخوله بعد) يُرفض لا يمرّ', async () => { + expect(await signing.verify(TENANT, 'ghost', 'a'.repeat(64), now(), 'POST', '/p', BODY)).toBe( + 'no_signing_key', + ); + }); + + it('دخول جديد يُبطل مفتاح الجلسة السابقة', async () => { + const first = await signing.issue(TENANT, USER); + const second = await signing.issue(TENANT, USER); + expect(first).not.toBe(second); + + const ts = now(); + expect(await signing.verify(TENANT, USER, sign(first, ts), ts, 'POST', '/p', BODY)).toBe( + 'bad_signature', + ); + }); + + it('السحب يُبطل التوقيع', async () => { + const secret = await signing.issue(TENANT, USER); + await signing.revoke(TENANT, USER); + const ts = now(); + expect(await signing.verify(TENANT, USER, sign(secret, ts), ts, 'POST', '/p', BODY)).toBe( + 'no_signing_key', + ); + }); + + it('توقيع بطول مختلف يُرفض بلا انهيار (timingSafeEqual يشترط الطول)', async () => { + const secret = await signing.issue(TENANT, USER); + const ts = now(); + expect(await signing.verify(TENANT, USER, 'short', ts, 'POST', '/p', BODY)).toBe('bad_signature'); + }); +}); diff --git a/backend/src/common/signing/signing.service.ts b/backend/src/common/signing/signing.service.ts new file mode 100644 index 0000000..dc3a5ee --- /dev/null +++ b/backend/src/common/signing/signing.service.ts @@ -0,0 +1,79 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { createHmac, randomBytes, timingSafeEqual } from 'crypto'; +import Redis from 'ioredis'; +import { REDIS } from '../redis/redis.module'; + +/** عمر مفتاح التوقيع — يطابق عمر توكن التحديث. */ +const KEY_TTL_SEC = 30 * 24 * 3600; +/** نافذة انحراف الساعة المسموحة للطابع الزمني. */ +export const SIGNATURE_SKEW_SEC = 300; + +/** + * توقيع العمليات المالية (docs/17 — I6/D3). + * + * مفتاح **لكل جلسة** يُولَّد على السيرفر عند الدخول — لا سرّ ثابت في التطبيق + * (السرّ الثابت يُستخرج بالهندسة العكسية فيصير التوقيع بلا معنى). + */ +@Injectable() +export class SigningService { + constructor(@Inject(REDIS) private readonly redis: Redis) {} + + private key(tenantId: string, userId: string) { + return `sign:${tenantId}:${userId}`; + } + + /** يُصدر مفتاحاً جديداً — الدخول من جهاز جديد يُبطل توقيع السابق. */ + async issue(tenantId: string, userId: string): Promise { + const secret = randomBytes(32).toString('hex'); + await this.redis.set(this.key(tenantId, userId), secret, 'EX', KEY_TTL_SEC); + return secret; + } + + async revoke(tenantId: string, userId: string): Promise { + await this.redis.del(this.key(tenantId, userId)); + } + + /** + * النصّ الموقَّع: `timestamp.METHOD.path.body`. + * تضمين المسار والطريقة يمنع إعادة استعمال توقيعٍ صالح على نقطة أخرى؛ + * والجسم يمنع تبديل المبلغ في الطريق (الـAPI على http حالياً). + */ + static payload(timestamp: string, method: string, path: string, rawBody: string): string { + return `${timestamp}.${method.toUpperCase()}.${path}.${rawBody ?? ''}`; + } + + static sign(secret: string, payload: string): string { + return createHmac('sha256', secret).update(payload).digest('hex'); + } + + /** + * يتحقق من التوقيع. يرجع سبب الفشل نصّاً (أو `null` عند النجاح) ليُسجَّل + * في التدقيق — لا ليُعرض للمهاجم. + */ + async verify( + tenantId: string, + userId: string, + signature: string, + timestamp: string, + method: string, + path: string, + rawBody: string, + ): Promise { + const ts = Number(timestamp); + if (!Number.isFinite(ts)) return 'bad_timestamp'; + // نافذة ضيقة = حماية من إعادة البثّ (replay) بلا تخزين حالة. + if (Math.abs(Date.now() / 1000 - ts) > SIGNATURE_SKEW_SEC) return 'stale_timestamp'; + + const secret = await this.redis.get(this.key(tenantId, userId)); + if (!secret) return 'no_signing_key'; // لم يُسجّل دخوله بعد هذه الميزة + + const expected = SigningService.sign(secret, SigningService.payload(timestamp, method, path, rawBody)); + return SigningService.safeEqual(signature, expected) ? null : 'bad_signature'; + } + + /** مقارنة ثابتة الزمن — العادية تسرّب التوقيع الصحيح بالتوقيت. */ + private static safeEqual(a: string, b: string): boolean { + if (typeof a !== 'string' || a.length !== b.length) return false; + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); + } +} diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index c55fc94..396ee91 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -83,6 +83,12 @@ export default () => ({ baseUrl: process.env.STORAGE_BASE_URL ?? '', }, + // المدفوعات: توقيع HMAC على العمليات المالية (docs/17 — I6). + // مطفأ حتى يوقّع تطبيق فلاتر؛ تفعيله قبل ذلك يقطع كل سحب. + payments: { + requireSignature: process.env.PAYMENTS_REQUIRE_SIGNATURE === 'true', + }, + // إشعارات FCM (اتركه فارغاً لتعطيل الإرسال — يُسجَّل فقط). fcm: { serverKey: process.env.FCM_SERVER_KEY ?? '', diff --git a/backend/src/database/migrations/1721870000000-PayoutSecurityAndAudit.ts b/backend/src/database/migrations/1721870000000-PayoutSecurityAndAudit.ts new file mode 100644 index 0000000..ff7005e --- /dev/null +++ b/backend/src/database/migrations/1721870000000-PayoutSecurityAndAudit.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * أمان السحب + سجل التدقيق (docs/17 — I4/I5/I7). + * - أعمدة إثبات هوية السحب على `pay_payouts`. + * - `audit_log` — من فعل ماذا ومتى ومن أين (append-only). + */ +export class PayoutSecurityAndAudit1721870000000 implements MigrationInterface { + public async up(q: QueryRunner): Promise { + const add = (col: string, type: string) => + q.query(`ALTER TABLE tripz_pay_payouts ADD COLUMN IF NOT EXISTS ${col} ${type}`); + + await add('otp_verified_at', 'timestamptz'); + await add('biometric_method', 'varchar'); + await add('biometric_at', 'timestamptz'); + await add('device_id', 'varchar'); + await add('request_ip', 'varchar'); + + // الافتراض الجديد للطلبات الجديدة: لا شيء يتحرك قبل التأكيد. + await q.query(`ALTER TABLE tripz_pay_payouts ALTER COLUMN status SET DEFAULT 'pending_otp'`); + + // السحوبات القائمة أُنشئت قبل وجود OTP وقد حُجز مالها فعلاً — تُعتبر + // مُتحقَّقة، وإلا رفض `complete` صرفها إلى الأبد. + await q.query(` + UPDATE tripz_pay_payouts + SET otp_verified_at = COALESCE(otp_verified_at, created_at) + WHERE status IN ('requested', 'processing', 'paid') + `); + + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_audit_log ( + id bigserial PRIMARY KEY, + tenant_id uuid NOT NULL, + actor_user_id uuid, + actor_role varchar, + action varchar NOT NULL, + subject_type varchar, + subject_id varchar, + amount numeric(12,3), + currency varchar, + meta jsonb NOT NULL DEFAULT '{}', + ip varchar, + created_at TIMESTAMP NOT NULL DEFAULT now() + ) + `); + await q.query(` + CREATE INDEX IF NOT EXISTS "IDX_tripz_audit_log_tenant_time" + ON tripz_audit_log (tenant_id, created_at) + `); + await q.query(` + CREATE INDEX IF NOT EXISTS "IDX_tripz_audit_log_subject" + ON tripz_audit_log (tenant_id, subject_type, subject_id) + `); + } + + public async down(q: QueryRunner): Promise { + await q.query(`DROP TABLE IF EXISTS tripz_audit_log`); + await q.query(`ALTER TABLE tripz_pay_payouts ALTER COLUMN status SET DEFAULT 'requested'`); + for (const c of ['request_ip', 'device_id', 'biometric_at', 'biometric_method', 'otp_verified_at']) { + await q.query(`ALTER TABLE tripz_pay_payouts DROP COLUMN IF EXISTS ${c}`); + } + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index ca66707..d612e73 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -7,7 +7,9 @@ import { AppModule } from './app.module'; import { RedisIoAdapter } from './realtime/redis-io.adapter'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + // rawBody: التوقيع يُحسب على الجسم **كما وصل** (docs/17 — I6). إعادة تسلسل + // JSON قد تغيّر ترتيب المفاتيح أو المسافات فيفشل توقيع سليم. + const app = await NestFactory.create(AppModule, { rawBody: true }); const cfg = app.get(ConfigService); // مُحوّل Socket.IO عبر Redis — يفعّل التوسّع الأفقي (عدة نسخ) @@ -35,6 +37,15 @@ async function bootstrap() { 'Security', ); } + if (!process.env.PLATFORM_SECRET) { + Logger.warn('PLATFORM_SECRET غير مضبوط — كل نقاط السوبر-أدمن مغلقة.', 'Security'); + } + if (cfg.get('payments.requireSignature') !== true) { + Logger.warn( + 'PAYMENTS_REQUIRE_SIGNATURE=false — العمليات المالية غير موقَّعة. فعّله بعد أن يوقّع تطبيق فلاتر (docs/17 I6).', + 'Security', + ); + } const port = cfg.get('apiPort') ?? 4010; await app.listen(port, '0.0.0.0'); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 711a4a9..06b297c 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import Redis from 'ioredis'; import { REDIS } from '../../common/redis/redis.module'; +import { SigningService } from '../../common/signing/signing.service'; import { UsersService } from '../users/users.service'; import { User } from '../users/entities/user.entity'; import { TenantsService } from '../tenants/tenants.service'; @@ -19,6 +20,7 @@ export class AuthService { private config: ConfigService, private tenantsService: TenantsService, private nabeh: NabehService, + private readonly signing: SigningService, @Inject(REDIS) private readonly redis: Redis, ) {} @@ -107,7 +109,7 @@ export class AuthService { return this.issueTokens(user, user.tenant_id); } - private issueTokens(user: User, tenantId: string) { + private async issueTokens(user: User, tenantId: string) { const base = { sub: user.id, phone: user.phone, @@ -120,6 +122,14 @@ export class AuthService { { ...base, type: 'refresh' }, { expiresIn: (this.config.get('jwt.refreshExpires') ?? '30d') as any }, ), + // مفتاح توقيع العمليات المالية (docs/17 — I6). يُسلَّم مرة واحدة عند + // الدخول ويُخزَّن في flutter_secure_storage. + // + // **لماذا مفتاح لكل جلسة لا سرّ ثابت في التطبيق؟** أي سرّ داخل التطبيق + // يُستخرج بالهندسة العكسية فيصير التوقيع مسرحية. المفتاح هنا يُولَّد على + // السيرفر لكل دخول، فمن يفكّك الـAPK لا يجد شيئاً، ومن يسرق توكناً + // (الـAPI على http حالياً) لا يملك المفتاح فلا يستطيع توقيع سحب. + signing_key: await this.signing.issue(tenantId, user.id), user, }; } diff --git a/backend/src/modules/payments/entities/payout.entity.ts b/backend/src/modules/payments/entities/payout.entity.ts index 6bd0476..6bb460b 100644 --- a/backend/src/modules/payments/entities/payout.entity.ts +++ b/backend/src/modules/payments/entities/payout.entity.ts @@ -7,7 +7,13 @@ import { UpdateDateColumn, } from 'typeorm'; -export type PayoutStatus = 'requested' | 'processing' | 'paid' | 'failed'; +export type PayoutStatus = + | 'pending_otp' // أُنشئ وأُرسل الرمز — **لا مال محجوز بعد** (docs/17 — I4) + | 'requested' // تحقّقت الهوية وحُجز المبلغ + | 'processing' + | 'paid' + | 'failed' + | 'expired'; // لم يُؤكَّد الرمز /** * طلب سحب أرباح سائق. الجدول: tripz_pay_payouts. @@ -37,12 +43,31 @@ export class Payout { @Column({ type: 'jsonb', default: {} }) destination: Record; - @Column({ type: 'varchar', default: 'requested' }) + @Column({ type: 'varchar', default: 'pending_otp' }) status: PayoutStatus; @Column({ type: 'varchar', nullable: true }) ref: string | null; + // ---- إثبات هوية السحب (docs/17 — I4/I5) ---- + /** لحظة تأكيد رمز واتساب — بدونها لا يُحجز مال ولا يُنفَّذ تحويل. */ + @Column({ type: 'timestamptz', nullable: true }) + otp_verified_at: Date | null; + + /** التأكيد الحيوي من فلاتر: face | fingerprint | device_credential | none. */ + @Column({ type: 'varchar', nullable: true }) + biometric_method: string | null; + + @Column({ type: 'timestamptz', nullable: true }) + biometric_at: Date | null; + + /** بصمة الجهاز ومصدر الطلب — أثرٌ للتحقيق عند النزاع (docs/17 — D2). */ + @Column({ type: 'varchar', nullable: true }) + device_id: string | null; + + @Column({ type: 'varchar', nullable: true }) + request_ip: string | null; + @CreateDateColumn() created_at: Date; diff --git a/backend/src/modules/payments/payments.module.ts b/backend/src/modules/payments/payments.module.ts index bb02b61..f7d5b07 100644 --- a/backend/src/modules/payments/payments.module.ts +++ b/backend/src/modules/payments/payments.module.ts @@ -7,9 +7,11 @@ import { PayoutsService } from './payouts.service'; import { PaymentsController } from './payments.controller'; import { PayoutsController } from './payouts.controller'; import { WalletModule } from '../wallet/wallet.module'; +import { UsersModule } from '../users/users.module'; @Module({ - imports: [TypeOrmModule.forFeature([Payment, Payout]), WalletModule], + // NabehModule و AuditModule عالميان. + imports: [TypeOrmModule.forFeature([Payment, Payout]), WalletModule, UsersModule], controllers: [PaymentsController, PayoutsController], providers: [PaymentsService, PayoutsService], exports: [PaymentsService, PayoutsService], diff --git a/backend/src/modules/payments/payouts.controller.ts b/backend/src/modules/payments/payouts.controller.ts index 999f9ab..902ef85 100644 --- a/backend/src/modules/payments/payouts.controller.ts +++ b/backend/src/modules/payments/payouts.controller.ts @@ -1,10 +1,20 @@ -import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { PayoutsService } from './payouts.service'; +import { PayoutsService, RequestContext } from './payouts.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; +import { SignatureGuard } from '../../common/signing/signature.guard'; + +/** IP يُؤخذ من الطلب لا من الجسم — العميل لا يُملي عنوانه. */ +function ctxOf(req: any, body?: any): RequestContext { + return { + ip: req?.ip ?? req?.socket?.remoteAddress ?? null, + deviceId: (req?.headers?.['x-device-id'] as string) ?? null, + biometricMethod: body?.biometric_method ?? null, + }; +} @ApiTags('payouts') @ApiBearerAuth() @@ -12,16 +22,42 @@ import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator export class PayoutsController { constructor(private readonly payouts: PayoutsService) {} - // السائق يطلب سحب أرباحه - @UseGuards(JwtAuthGuard) + /** + * الخطوة 1: السائق يطلب السحب → يصله رمز على واتساب. + * **لا يُخصم شيء هنا** (docs/17 — I4). + */ + @UseGuards(JwtAuthGuard, SignatureGuard) @Post('request') - request(@CurrentUser() user: AuthUser, @Body() body: any) { - return this.payouts.request(user.tenantId, user.userId, { - amount: Number(body.amount), - channel: body.channel, - currency: body.currency, - destination: body.destination, - }); + request(@CurrentUser() user: AuthUser, @Body() body: any, @Req() req: any) { + return this.payouts.request( + user.tenantId, + user.userId, + { + amount: Number(body.amount), + channel: body.channel, + currency: body.currency, + destination: body.destination, + }, + ctxOf(req, body), + ); + } + + /** + * الخطوة 2: تأكيد الرمز → يُحجز المبلغ. + * + * `biometric_method` هو ما أثبته فلاتر محلياً (وجه/إصبع). يُسجَّل **كأثر + * فقط ولا يُعتمد كمصادقة** — العميل يستطيع ادّعاءه. المصادقة الحقيقية هي + * JWT + رمز واتساب (docs/17 — I5). + */ + @UseGuards(JwtAuthGuard, SignatureGuard) + @Post(':id/confirm') + confirm( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { code: string; biometric_method?: string }, + @Req() req: any, + ) { + return this.payouts.confirm(user.tenantId, user.userId, id, body.code, ctxOf(req, body)); } @UseGuards(JwtAuthGuard) @@ -34,14 +70,22 @@ export class PayoutsController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin', 'dispatcher') @Patch(':id/complete') - complete(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body('ref') ref: string) { - return this.payouts.complete(user.tenantId, id, ref); + complete( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body('ref') ref: string, + @Req() req: any, + ) { + return this.payouts.complete(user.tenantId, id, ref, { + userId: user.userId, + ip: ctxOf(req).ip, + }); } @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin', 'dispatcher') @Patch(':id/fail') - fail(@CurrentUser() user: AuthUser, @Param('id') id: string) { - return this.payouts.fail(user.tenantId, id); + fail(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() req: any) { + return this.payouts.fail(user.tenantId, id, { userId: user.userId, ip: ctxOf(req).ip }); } } diff --git a/backend/src/modules/payments/payouts.service.ts b/backend/src/modules/payments/payouts.service.ts index 9081b0f..fddabbc 100644 --- a/backend/src/modules/payments/payouts.service.ts +++ b/backend/src/modules/payments/payouts.service.ts @@ -1,8 +1,23 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + Logger, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { randomInt } from 'crypto'; +import Redis from 'ioredis'; +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 { AuditService } from '../../common/audit/audit.service'; export interface PayoutRequestDto { amount: number; @@ -11,36 +26,143 @@ export interface PayoutRequestDto { destination?: Record; } +/** سياق الطلب — أثرٌ للتحقيق، وليس مصدر هوية. */ +export interface RequestContext { + ip?: string | null; + deviceId?: string | null; + biometricMethod?: string | null; +} + +const OTP_TTL_SEC = 300; +const OTP_MAX_ATTEMPTS = 5; + /** - * سحب أرباح السائق: يحجز المبلغ من المحفظة فوراً (debit)، ثم يُحوَّل خارجياً. - * الفشل يُعيد المبلغ للمحفظة. التحويل الخارجي الفعلي يُنفَّذ يدوياً/بمزوّد لاحقاً. + * سحب أرباح السائق (docs/17 — I4/I5/I7). + * + * **قاعدة التصميم: لا يتحرك مال قبل إثبات الهوية.** + * الطلب يرسل رمزاً عبر واتساب ولا يحجز شيئاً؛ الحجز يقع عند التأكيد فقط. + * (عند سيرو: لا حجز إطلاقاً ولا تحقق — راجع تدقيق المجموعة I في docs/17.) */ @Injectable() export class PayoutsService { + private readonly logger = new Logger('Payouts'); + constructor( @InjectRepository(Payout) private readonly repo: Repository, + @Inject(REDIS) private readonly redis: Redis, private readonly wallet: WalletService, + private readonly users: UsersService, + private readonly nabeh: NabehService, + private readonly audit: AuditService, + private readonly config: ConfigService, ) {} - async request(tenantId: string, driverUserId: string, dto: PayoutRequestDto) { + private otpKey(payoutId: string) { + return `payout:otp:${payoutId}`; + } + + private get devMode(): boolean { + return this.config.get('auth.otpDevMode') !== false; + } + + /** + * الخطوة 1: يُنشئ الطلب ويرسل رمزاً — **بلا أي حركة مال**. + * `driverUserId` من التوكن دائماً لا من الجسم (ثغرة IDOR عند سيرو). + */ + async request( + tenantId: string, + driverUserId: string, + dto: PayoutRequestDto, + ctx: RequestContext = {}, + ) { const amount = Number(dto.amount); if (!(amount > 0)) throw new BadRequestException('amount must be > 0'); if (!dto.channel) throw new BadRequestException('channel is required'); - // يحجز المبلغ (يرمي لو الرصيد غير كافٍ) - await this.wallet.debit(tenantId, driverUserId, amount, 'payout_hold'); + // فحص مبكّر — لا نرسل رمزاً لطلب مستحيل. **ليس حجزاً**: الحجز الحقيقي + // ذرّي عند التأكيد، فلا فجوة بين الفحص والخصم. + const w = await this.wallet.getOrCreate(tenantId, driverUserId); + if (Number(w.balance) < amount) throw new BadRequestException('Insufficient balance'); - return this.repo.save( + const payout = await this.repo.save( this.repo.create({ tenant_id: tenantId, driver_user_id: driverUserId, amount, - currency: dto.currency ?? 'JOD', + currency: dto.currency ?? w.currency ?? 'JOD', channel: dto.channel, destination: dto.destination ?? {}, - status: 'requested', + status: 'pending_otp', + device_id: ctx.deviceId ?? null, + request_ip: ctx.ip ?? null, }), ); + + const code = await this.issueOtp(tenantId, driverUserId, payout.id); + + await this.audit.record({ + tenantId, + actorUserId: driverUserId, + actorRole: 'driver', + action: 'payout.request', + subjectType: 'payout', + subjectId: payout.id, + amount, + currency: payout.currency, + ip: ctx.ip, + meta: { channel: payout.channel, deviceId: ctx.deviceId ?? null }, + }); + + return { + payout, + otp_sent: true, + // وضع التطوير فقط — لا يُسرَّب الرمز في الإنتاج. + ...(this.devMode ? { dev_code: code } : {}), + }; + } + + /** + * الخطوة 2: تأكيد الرمز → **هنا فقط** يُحجز المال (الخصم الذرّي من I1). + * التأكيد الحيوي القادم من فلاتر يُسجَّل معه (docs/17 — I5). + */ + async confirm( + tenantId: string, + driverUserId: string, + payoutId: string, + code: string, + ctx: RequestContext = {}, + ) { + const p = await this.getOr404(tenantId, payoutId); + // السائق يؤكّد طلبه هو فقط — التوكن هو المرجع. + if (p.driver_user_id !== driverUserId) throw new ForbiddenException('not your payout'); + if (p.status !== 'pending_otp') throw new BadRequestException('payout is not awaiting a code'); + + await this.verifyOtp(payoutId, code, tenantId, driverUserId, ctx); + + // الحجز الذرّي: يرمي «رصيد غير كافٍ» لو تغيّر الرصيد منذ الطلب. + await this.wallet.debit(tenantId, driverUserId, Number(p.amount), 'payout_hold', p.id); + + p.status = 'requested'; + p.otp_verified_at = new Date(); + if (ctx.biometricMethod) { + p.biometric_method = ctx.biometricMethod; + p.biometric_at = new Date(); + } + const saved = await this.repo.save(p); + + await this.audit.record({ + tenantId, + actorUserId: driverUserId, + actorRole: 'driver', + action: 'payout.confirm', + subjectType: 'payout', + subjectId: p.id, + amount: Number(p.amount), + currency: p.currency, + ip: ctx.ip, + meta: { biometric: ctx.biometricMethod ?? 'none', deviceId: ctx.deviceId ?? null }, + }); + return saved; } listMine(tenantId: string, driverUserId: string) { @@ -50,29 +172,131 @@ export class PayoutsService { }); } + /** الأدمن يؤكّد أن المبلغ حُوِّل خارجياً. */ + async complete( + tenantId: string, + id: string, + ref?: string, + actor?: { userId: string; ip?: string | null }, + ) { + const p = await this.getOr404(tenantId, id); + if (p.status === 'paid') return p; + // لا يُدفع طلبٌ لم تُثبَت هويته — حارس ضد تخطّي خطوة التأكيد. + if (!p.otp_verified_at) throw new BadRequestException('payout was never verified'); + + p.status = 'paid'; + p.ref = ref ?? p.ref; + const saved = await this.repo.save(p); + + await this.audit.record({ + tenantId, + actorUserId: actor?.userId ?? null, + actorRole: 'admin', + action: 'payout.complete', + subjectType: 'payout', + subjectId: p.id, + amount: Number(p.amount), + currency: p.currency, + ip: actor?.ip, + meta: { ref: ref ?? null }, + }); + return saved; + } + + /** فشل التحويل — يُعاد المبلغ للمحفظة (فقط إن كان قد حُجز أصلاً). */ + async fail(tenantId: string, id: string, actor?: { userId: string; ip?: string | null }) { + const p = await this.getOr404(tenantId, id); + if (p.status === 'paid') throw new BadRequestException('already paid'); + + // `pending_otp` لم يُخصم منه شيء — ردّه يخلق مالاً من العدم. + const wasHeld = ['requested', 'processing'].includes(p.status); + if (wasHeld) { + await this.wallet.credit(tenantId, p.driver_user_id, Number(p.amount), 'payout_refund', p.id); + } + p.status = 'failed'; + const saved = await this.repo.save(p); + + await this.audit.record({ + tenantId, + actorUserId: actor?.userId ?? null, + actorRole: 'admin', + action: 'payout.fail', + subjectType: 'payout', + subjectId: p.id, + amount: Number(p.amount), + currency: p.currency, + ip: actor?.ip, + meta: { refunded: wasHeld }, + }); + return saved; + } + + // ---- داخلي ---- + private async getOr404(tenantId: string, id: string): Promise { const p = await this.repo.findOne({ where: { tenant_id: tenantId, id } }); if (!p) throw new NotFoundException('payout not found'); return p; } - /** الأدمن يؤكّد أن المبلغ حُوِّل خارجياً. */ - async complete(tenantId: string, id: string, ref?: string) { - const p = await this.getOr404(tenantId, id); - if (p.status === 'paid') return p; - p.status = 'paid'; - p.ref = ref ?? p.ref; - return this.repo.save(p); + private async issueOtp( + tenantId: string, + driverUserId: string, + payoutId: string, + ): Promise { + const code = String(randomInt(1000, 10000)); + await this.redis.set(this.otpKey(payoutId), code, 'EX', OTP_TTL_SEC); + + if (this.devMode) { + this.logger.log(`payout OTP (dev) payout=${payoutId} => ${code}`); + return code; + } + 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); + return code; } - /** فشل التحويل — يُعاد المبلغ للمحفظة. */ - async fail(tenantId: string, id: string) { - const p = await this.getOr404(tenantId, id); - if (p.status === 'paid') throw new BadRequestException('already paid'); - if (p.status !== 'failed') { - await this.wallet.credit(tenantId, p.driver_user_id, Number(p.amount), 'payout_refund', p.id); + private async verifyOtp( + payoutId: string, + code: string, + tenantId: string, + driverUserId: string, + ctx: RequestContext, + ): Promise { + // حدّ المحاولات: بلا حدّ يُخمَّن رمزٌ من 4 خانات في دقائق. + const attemptsKey = `${this.otpKey(payoutId)}:attempts`; + const attempts = await this.redis.incr(attemptsKey); + if (attempts === 1) await this.redis.expire(attemptsKey, OTP_TTL_SEC); + if (attempts > OTP_MAX_ATTEMPTS) { + await this.redis.del(this.otpKey(payoutId)); + await this.audit.record({ + tenantId, + actorUserId: driverUserId, + action: 'payout.otp_blocked', + subjectType: 'payout', + subjectId: payoutId, + ip: ctx.ip, + meta: { attempts }, + }); + throw new UnauthorizedException('too many attempts — request a new payout'); } - p.status = 'failed'; - return this.repo.save(p); + + const stored = await this.redis.get(this.otpKey(payoutId)); + if (!stored || stored !== String(code ?? '')) { + await this.audit.record({ + tenantId, + actorUserId: driverUserId, + action: 'payout.otp_failed', + subjectType: 'payout', + subjectId: payoutId, + ip: ctx.ip, + meta: { attempts }, + }); + throw new UnauthorizedException('Invalid or expired code'); + } + // يُستهلك مرة واحدة — لا إعادة استعمال. + await this.redis.del(this.otpKey(payoutId), attemptsKey); } } diff --git a/docs/17-backend-backlog.md b/docs/17-backend-backlog.md index bf40641..108596a 100644 --- a/docs/17-backend-backlog.md +++ b/docs/17-backend-backlog.md @@ -166,13 +166,20 @@ | # | البند | التفصيل | |---|-------|---------| | I1 | ✅ **إصلاح سباق المحفظة** | **منفَّذ ومُثبَت على السيرفر** (commit 9d6b752): `UPDATE … SET balance = balance ± :delta WHERE tenant_id … AND balance >= :amount RETURNING *` — عبارة واحدة ذرّية، والقيد+الرصيد في معاملة واحدة. أُضيف `wallet_txns.balance_after` وقيد `CHECK (balance >= 0)` كشبكة أمان. إنشاء المحفظة عبر `ON CONFLICT DO NOTHING`.
**نتيجة `wallet-race-test.mjs 100 5` على Postgres حقيقي:** 100 خصم متزامن → نجح 50 بالضبط، رُفض 50، الرصيد 250→0، المخصوم = 250 (لا مال ضائع ولا مخلوق)، الزمن 1492ms. ✅ | -| I2 | **محفظة لكل طرف + محفظة المنصة** | راكب · سائق · المستأجر/المنصة (مكافئ `siroWallet`) — أساس العمولة (B6). | -| I3 | **جدول لكل طريقة دفع** | شام كاش · كليك · إي كاش · بيموب · MTN · فوري … لكل واحدة جدولها + محوّل (adapter) موحّد. حالياً عندنا `tripz_pay_payments` عام. | -| I4 | **OTP على الـpayout عبر نبيه** | إرسال كود + تحقّق قبل تنفيذ السحب (لا يوجد في سيرو). | -| I5 | **بصمة (وجه/إصبع) في فلاتر** | تأكيد حيوي قبل السحب — يُربط بالطلب (device fingerprint من D2). | -| I6 | **HMAC على العمليات المالية** | توقيع الطلب (D3) — إلزامي على topup/payout. | -| I7 | **سجل تدقيق مالي** | مكافئ `admin_audit_log` — من فعل ماذا ومتى على كل عملية. | -| I8 | **webhook SMS + Gemini** | تبنّي نمط سيرو للأسواق بلا API رسمي (سوريا): `raw_sms_log` + استخراج بالـAI + تسوية. | +| # | البند | الحالة | +|---|-------|--------| +| I4 | **OTP على الـpayout عبر نبيه** | ✅ تدفّق خطوتين: `POST /payouts/request` يرسل رمزاً **بلا حركة مال**، و`POST /payouts/:id/confirm` يتحقق ثم يحجز ذرّياً. حدّ 5 محاولات، الرمز يُستهلك مرة واحدة، و`complete` يرفض طلباً بلا `otp_verified_at`. **قاعدة: لا يتحرك مال قبل إثبات الهوية.** | +| I5 | **بصمة (وجه/إصبع)** | ✅ الجزء الخلفي: `biometric_method`/`biometric_at` + `device_id` + `request_ip` تُسجَّل مع السحب. **أثرٌ للتحقيق لا مصادقة** — العميل يستطيع ادّعاءها؛ المصادقة الحقيقية JWT + رمز واتساب. الإثبات الحيّ نفسه في فلاتر. | +| I6 | **HMAC على العمليات المالية** | 🟡 **مبنيّ ومطفأ** (`PAYMENTS_REQUIRE_SIGNATURE=false`) — يحتاج فلاتر أن يوقّع أولاً، وتفعيله قبل ذلك يقطع كل سحب. **مفتاح لكل جلسة** يُصدره الدخول (`signing_key`) لا سرّ ثابت في التطبيق (الثابت يُستخرج بالهندسة العكسية فيصير التوقيع مسرحية). يوقّع `timestamp.METHOD.path.body` بنافذة 5 دقائق. | +| I7 | **سجل تدقيق مالي** | ✅ `tripz_audit_log` (append-only): من · ماذا · متى · من أي IP وجهاز. يغطّي `payout.request/confirm/complete/fail` و`otp_failed`/`otp_blocked` و`signature.rejected`. **لا يرمي أبداً** — فشل التدقيق لا يُسقط عمليةً نجحت. | +| I2 | **محفظة المنصة** | ⏳ **أُلغيت الحاجة إليها عملياً**: بعد نموذج الرصيد التشغيلي (docs/18) لم تعد المنصة تقبض عمولة من كل رحلة — إيرادها = **الشحن**، و`credit_txns` هو دفتر ذلك الإيراد فعلاً (`SUM` الشحن لكل مستأجر). محفظة ثانية = مسك دفتر مزدوج يحتاج مطابقة. البند الباقي = **تقرير إيراد** لا محفظة. | +| I3 | **جدول لكل طريقة دفع** | ⏳ لم يُنفَّذ — إعادة هيكلة مخطط كاملة (`tripz_pay_payments` عام حالياً). يُنفَّذ مع أول ربط بوابة حقيقية، لا قبله. | +| I8 | **webhook SMS + Gemini** | ⏳ لم يُنفَّذ — ميزة كاملة (`raw_sms_log` + استخراج + تسوية). تُبنى عند دخول السوق السوري فعلياً. | + +### 🔴 أخطر ثغرة مالية قائمة — ليست في الكود +**الـAPI يعمل على `http` بلا TLS** (`194.163.173.157:4010`، والتطبيق يفعّل `usesCleartextTraffic=true`). من يلتقط الشبكة يسرق توكن أي سائق ويسحب أرباحه — ولا OTP ولا HMAC ولا تدقيق يمنع ذلك تماماً. +- توقيع I6 **يخفّف** الضرر (التوكن المسروق وحده لا يكفي: المفتاح لا يمرّ في الشبكة بعد الدخول) لكنه **ترقيع لا بديل**. +- **الإصلاح الحقيقي: TLS** (نطاق + شهادة عبر CloudPanel/Let's Encrypt) ثم `usesCleartextTraffic=false`. **يسبق أي عمل مدفوعات آخر.** ---