diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index fa99b5f..04da827 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -11,6 +11,8 @@ 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 { PhoneModule } from './common/phone/phone.module'; +import { DeviceModule } from './common/device/device.module'; import { SeedModule } from './common/seed/seed.module'; import { HealthModule } from './modules/health/health.module'; import { TenantsModule } from './modules/tenants/tenants.module'; @@ -68,6 +70,8 @@ import { GeminiModule } from './integrations/gemini/gemini.module'; PlatformModule, // عالمي — حارس السوبر-أدمن (x-platform-secret) AuditModule, // عالمي — سجل التدقيق المالي SigningModule, // عالمي — توقيع HMAC للعمليات المالية + PhoneModule, // عالمي — تطبيع أرقام الهاتف لكل دولة + DeviceModule, // عالمي — ربط الجلسة بالجهاز NabehModule, // عالمي — إرسال OTP واتساب NotificationsModule, // عالمي — FCM StorageModule, // عالمي — تخزين ملفات الوثائق diff --git a/backend/src/common/device/device.module.ts b/backend/src/common/device/device.module.ts new file mode 100644 index 0000000..652eb90 --- /dev/null +++ b/backend/src/common/device/device.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { DeviceService } from './device.service'; + +@Global() +@Module({ + providers: [DeviceService], + exports: [DeviceService], +}) +export class DeviceModule {} diff --git a/backend/src/common/device/device.service.ts b/backend/src/common/device/device.service.ts new file mode 100644 index 0000000..539cd2b --- /dev/null +++ b/backend/src/common/device/device.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { createHash } from 'crypto'; + +/** + * ربط الجلسة بالجهاز (docs/17 — D2؛ نمط سيرو). + * + * فلاتر يولّد بصمة جهاز محلية (device_info_plus) ويرسلها **مرة عند الدخول** + * ثم في ترويسة `x-device-id` مع كل طلب لاحق. البصمة الخام لا تُخزَّن — فقط + * بصمتها (hash) داخل التوكن، بنفس منطق عدم تخزين أسرار خام. + */ +@Injectable() +export class DeviceService { + hash(rawDeviceId: string): string { + return createHash('sha256').update(rawDeviceId ?? '').digest('hex'); + } +} diff --git a/backend/src/common/phone/phone.module.ts b/backend/src/common/phone/phone.module.ts new file mode 100644 index 0000000..deba55f --- /dev/null +++ b/backend/src/common/phone/phone.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PhoneService } from './phone.service'; + +@Global() +@Module({ + providers: [PhoneService], + exports: [PhoneService], +}) +export class PhoneModule {} diff --git a/backend/src/common/phone/phone.service.spec.ts b/backend/src/common/phone/phone.service.spec.ts new file mode 100644 index 0000000..c017c22 --- /dev/null +++ b/backend/src/common/phone/phone.service.spec.ts @@ -0,0 +1,84 @@ +import { BadRequestException } from '@nestjs/common'; +import { PhoneService } from './phone.service'; + +describe('PhoneService — تطبيع أرقام الهاتف (docs/17 D1)', () => { + let phones: PhoneService; + + beforeEach(() => { + phones = new PhoneService(); + }); + + describe('الأردن (jo)', () => { + it('الصيغة المحلية بالصفر', () => { + expect(phones.normalize('0790000000', 'jo')).toBe('962790000000'); + }); + + it('الصيغة الدولية بالفعل', () => { + expect(phones.normalize('962790000000', 'jo')).toBe('962790000000'); + }); + + it('بادئة + دولية', () => { + expect(phones.normalize('+962790000000', 'jo')).toBe('962790000000'); + }); + + it('بادئة 00 بدل +', () => { + expect(phones.normalize('00962790000000', 'jo')).toBe('962790000000'); + }); + + it('فراغات وشرطات لا تؤثر', () => { + expect(phones.normalize('+962 79-000-0000', 'jo')).toBe('962790000000'); + }); + + it('محلي بلا صفر بادئ', () => { + expect(phones.normalize('790000000', 'jo')).toBe('962790000000'); + }); + }); + + describe('مصر (eg) — لبس المالك: "01" يُظنّ مفتاحاً وهو ليس كذلك', () => { + it('الصيغة المحلية بالصفر (11 رقماً) تُحسب بمفتاح 20 الصحيح لا "2"', () => { + const r = phones.normalize('01012345678', 'eg'); + expect(r).toBe('201012345678'); + expect(r.startsWith('20')).toBe(true); // لا "2" فقط + }); + + it('الصيغة الدولية بالفعل', () => { + expect(phones.normalize('201012345678', 'eg')).toBe('201012345678'); + }); + + it('بادئة +', () => { + expect(phones.normalize('+201012345678', 'eg')).toBe('201012345678'); + }); + }); + + describe('سوريا (sy)', () => { + it('الصيغة المحلية بالصفر', () => { + expect(phones.normalize('0944000000', 'sy')).toBe('963944000000'); + }); + }); + + describe('نفس الرقم الحقيقي بصيغ مختلفة → مفتاح واحد فقط', () => { + it('لا يتعدد الحساب لنفس الرقم', () => { + const variants = ['0790000000', '962790000000', '+962790000000', '00962790000000']; + const results = new Set(variants.map((v) => phones.normalize(v, 'jo'))); + expect(results.size).toBe(1); + }); + }); + + describe('مدخلات غير صالحة', () => { + it('طول خاطئ يُرفض لا يُخمَّن', () => { + expect(() => phones.normalize('12345', 'jo')).toThrow(BadRequestException); + }); + + it('نص فارغ يُرفض', () => { + expect(() => phones.normalize('', 'jo')).toThrow(BadRequestException); + }); + + it('حروف بلا أرقام تُرفض', () => { + expect(() => phones.normalize('abc-def', 'jo')).toThrow(BadRequestException); + }); + }); + + it('دولة غير معروفة تسقط لخطة افتراضية بلا انهيار', () => { + expect(() => phones.normalize('0790000000', 'xx')).not.toThrow(); + }); +}); diff --git a/backend/src/common/phone/phone.service.ts b/backend/src/common/phone/phone.service.ts new file mode 100644 index 0000000..fb17248 --- /dev/null +++ b/backend/src/common/phone/phone.service.ts @@ -0,0 +1,76 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +/** + * قاعدة ترقيم كل دولة (docs/17 — D1). + * `trunkPrefix`: الصفر المحلي الذي يُكتب فيُحذف قبل إضافة مفتاح الدولة. + * `nationalLength`: طول الرقم المحلي **بعد** حذف الصفر. + */ +interface DialPlan { + callingCode: string; // بلا + + nationalLength: number; +} + +const DIAL_PLANS: Record = { + // 07XXXXXXXX (10 أرقام بالصفر) → مفتاح 962 + 7XXXXXXXX (9 أرقام) + jo: { callingCode: '962', nationalLength: 9 }, + // 09XXXXXXXX (10 أرقام بالصفر) → مفتاح 963 + 9XXXXXXXX (9 أرقام) + sy: { callingCode: '963', nationalLength: 9 }, + // 01XXXXXXXXX (11 رقماً بالصفر) → مفتاح 20 + 1XXXXXXXXX (10 أرقام). + // هذا بالضبط اللبس الذي ذكره المالك: الناس تظن المفتاح "2" لا "20"، لأن + // الرقم المحلي نفسه يبدأ بـ1 فيبدو للوهلة الأولى أن "01" بأكملها مفتاح. + eg: { callingCode: '20', nationalLength: 10 }, +}; + +/** + * تطبيع رقم الهاتف لصيغة دولية قانونية موحّدة (docs/17 — D1). + * + * **بلا هذا**: نفس الرقم الحقيقي يُكتب "0790000000" مرة و"962790000000" + * مرة و"+962790000000" مرة ثالثة — فيصير له 3 حسابات مختلفة. المفتاح + * المخزَّن في القاعدة يجب أن يكون **دالة واحدة فقط** لكل رقم حقيقي. + */ +@Injectable() +export class PhoneService { + /** + * يرجع الصيغة الدولية بلا "+" (مثل "962790000000") — هذا هو المفتاح + * القانوني الوحيد المخزَّن والمُقارَن به في كل مكان. + */ + normalize(raw: string, countryPack: string): string { + const plan = DIAL_PLANS[countryPack] ?? DIAL_PLANS.jo; + const digits = PhoneService.stripToDigits(raw); + if (!digits) throw new BadRequestException('invalid phone number'); + + // "00" بادئة دولية بديلة عن "+" — إزالتها تعيدنا لحالة "مفتاح كامل". + const noIntlPrefix = digits.startsWith('00') ? digits.slice(2) : digits; + + // 1) مكتوب بمفتاح الدولة كاملاً (مع أو بلا +/00 — أُزيلا أعلاه). + if ( + noIntlPrefix.startsWith(plan.callingCode) && + noIntlPrefix.length === plan.callingCode.length + plan.nationalLength + ) { + return noIntlPrefix; + } + + // 2) الصيغة المحلية المعتادة: صفر بادئ ثم الرقم المحلي. + if (noIntlPrefix.startsWith('0') && noIntlPrefix.length === plan.nationalLength + 1) { + return plan.callingCode + noIntlPrefix.slice(1); + } + + // 3) رقم محلي بلا صفر بادئ (بعض المستخدمين يحذفونه بأنفسهم). + if (noIntlPrefix.length === plan.nationalLength) { + return plan.callingCode + noIntlPrefix; + } + + throw new BadRequestException( + `invalid phone number for country "${countryPack}"`, + ); + } + + /** للعرض/الإرسال فقط — Nabeh والتطبيق يريدان الصيغة الدولية بلا +. */ + toWhatsApp(normalized: string): string { + return normalized; + } + + private static stripToDigits(raw: string): string { + return (raw ?? '').replace(/[^\d]/g, ''); + } +} diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index 396ee91..5ddb0cc 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -40,6 +40,9 @@ export default () => ({ otpDevMode: process.env.OTP_DEV_MODE !== 'false', otpTtl: parseInt(process.env.OTP_TTL ?? '300', 10), otpLength: parseInt(process.env.OTP_LENGTH ?? '4', 10), + // ربط الجلسة بالجهاز (docs/17 — D2). مطفأ حتى يرسل فلاتر x-device-id؛ + // تفعيله قبل ذلك يقطع كل طلب مصادَق (نفس نمط PAYMENTS_REQUIRE_SIGNATURE). + requireDeviceBinding: process.env.AUTH_REQUIRE_DEVICE_BINDING === 'true', }, maps: { @@ -63,8 +66,8 @@ export default () => ({ otpType: process.env.NABEH_OTP_TYPE ?? 'text', }, - // رموز الاتصال الدولية لكل country pack (لتنسيق الهاتف قبل الإرسال). - callingCodes: { jo: '962', sy: '963' } as Record, + // ملاحظة: خرائط الاتصال الدولية صارت في common/phone/phone.service.ts + // (docs/17 — D1) — مصدر واحد للحقيقة بدل نسختين قد تتباعدان. // Gemini (رؤية) — قراءة الوثائق ومطابقة الوجه. gemini: { diff --git a/backend/src/database/migrations/1721880000000-NormalizePhones.ts b/backend/src/database/migrations/1721880000000-NormalizePhones.ts new file mode 100644 index 0000000..a859e2c --- /dev/null +++ b/backend/src/database/migrations/1721880000000-NormalizePhones.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * تطبيع أرقام الهاتف المخزَّنة مسبقاً (docs/17 — D1). + * + * قبل هذه الهجرة كانت `sendOtp`/`verifyOtp` تخزّنان الرقم **كما كُتب حرفياً** + * — فحسابات اختبار الحمل (`0797…`) وأي دخول سابق مخزَّنة بصيغة محلية لا + * دولية. تطبّق هذه الهجرة نفس منطق `PhoneService` بـSQL خام لكل مستأجر حسب + * `country_pack`، وتتخطّى أي صفٍّ يتعارض ناتجه مع صفٍّ آخر (لا تكسر القيد + * الفريد `(tenant_id, phone)`؛ التعارض النادر يُترك يدوياً). + */ +export class NormalizePhones1721880000000 implements MigrationInterface { + public async up(q: QueryRunner): Promise { + // jo/sy: صفر محلي + 9 أرقام → مفتاح الدولة + 9 أرقام. + await q.query(` + UPDATE tripz_users u + SET phone = t.country_pack_cc || substring(u.phone from 2) + FROM ( + SELECT id, CASE country_pack WHEN 'jo' THEN '962' WHEN 'sy' THEN '963' END AS country_pack_cc + FROM tripz_tenants WHERE country_pack IN ('jo', 'sy') + ) t + WHERE u.tenant_id = t.id + AND u.phone ~ '^0[0-9]{9}$' + AND NOT EXISTS ( + SELECT 1 FROM tripz_users u2 + WHERE u2.tenant_id = u.tenant_id + AND u2.phone = t.country_pack_cc || substring(u.phone from 2) + ) + `); + + // eg: صفر محلي + 10 أرقام → "20" + 10 أرقام. + await q.query(` + UPDATE tripz_users u + SET phone = '20' || substring(u.phone from 2) + FROM tripz_tenants t + WHERE u.tenant_id = t.id + AND t.country_pack = 'eg' + AND u.phone ~ '^0[0-9]{10}$' + AND NOT EXISTS ( + SELECT 1 FROM tripz_users u2 + WHERE u2.tenant_id = u.tenant_id AND u2.phone = '20' || substring(u.phone from 2) + ) + `); + } + + public async down(): Promise { + // لا رجوع — لا سبيل لمعرفة الصيغة الأصلية بعد التطبيع (وقد تكون كانت + // بصيغ مختلفة أصلاً). التطبيع أُحادي الاتجاه بطبيعته. + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 96e7bde..46e5393 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -53,6 +53,12 @@ async function bootstrap() { 'Security', ); } + if (cfg.get('auth.requireDeviceBinding') !== true) { + Logger.warn( + 'AUTH_REQUIRE_DEVICE_BINDING=false — التوكن المسروق يعمل من أي جهاز. فعّله بعد أن يرسل فلاتر x-device-id (docs/17 D2).', + 'Security', + ); + } const port = cfg.get('apiPort') ?? 4010; await app.listen(port, '0.0.0.0'); diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 78e475f..1a65285 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -19,16 +19,20 @@ export class AuthController { @Post('verify-otp') async verifyOtp( @Headers('x-tenant-id') tenantId: string, + @Headers('x-device-id') deviceId: string, @Body('phone') phone: string, @Body('code') code: string, ) { if (!tenantId) throw new UnauthorizedException('Tenant ID (x-tenant-id) is required'); - return this.authService.verifyOtp(tenantId, phone, code); + return this.authService.verifyOtp(tenantId, phone, code, deviceId); } @Post('refresh') - async refresh(@Body('refresh_token') refreshToken: string) { + async refresh( + @Headers('x-device-id') deviceId: string, + @Body('refresh_token') refreshToken: string, + ) { if (!refreshToken) throw new UnauthorizedException('refresh_token is required'); - return this.authService.refresh(refreshToken); + return this.authService.refresh(refreshToken, deviceId); } } diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 06b297c..05fa467 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -9,6 +9,8 @@ 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 { PhoneService } from '../../common/phone/phone.service'; +import { DeviceService } from '../../common/device/device.service'; @Injectable() export class AuthService { @@ -21,6 +23,8 @@ export class AuthService { private tenantsService: TenantsService, private nabeh: NabehService, private readonly signing: SigningService, + private readonly phones: PhoneService, + private readonly device: DeviceService, @Inject(REDIS) private readonly redis: Redis, ) {} @@ -46,55 +50,57 @@ export class AuthService { return c; } - /** يصيغ الهاتف لصيغة دولية بلا + (مثل 962790000000) حسب دولة المستأجر. */ - private formatPhone(phone: string, countryPack: string): string { - const codes = this.config.get>('callingCodes') ?? {}; - const cc = codes[countryPack] ?? '962'; - let p = phone.replace(/\D/g, ''); - if (p.startsWith('00')) p = p.slice(2); - if (p.startsWith('0')) p = cc + p.slice(1); - else if (!p.startsWith(cc)) p = cc + p; - return p; - } - async sendOtp(tenantSlug: string, phone: string) { const tenant = await this.resolveTenant(tenantSlug); + // تطبيع فوري (docs/17 — D1): كل ما يلي يتعامل مع الصيغة الدولية القانونية + // الوحيدة، لا مع ما كتبه المستخدم حرفياً. بدون هذا "0790000000" و + // "+962790000000" يصيران حسابين مختلفين لنفس الرقم الحقيقي. + const canonical = this.phones.normalize(phone, tenant.countryPack); + const code = this.genCode(); const ttl = this.config.get('auth.otpTtl') ?? 300; - await this.redis.set(this.otpKey(tenant.id, phone), code, 'EX', ttl); + await this.redis.set(this.otpKey(tenant.id, canonical), code, 'EX', ttl); if (this.devMode) { - this.logger.log(`OTP (dev) tenant=${tenant.slug} phone=${phone} => ${code}`); + this.logger.log(`OTP (dev) tenant=${tenant.slug} phone=${canonical} => ${code}`); return { success: true, message: 'OTP sent (dev)', dev_code: code }; } // إرسال حقيقي عبر واتساب (Nabeh) - const intl = this.formatPhone(phone, tenant.countryPack); - await this.nabeh.sendOtp(intl, code); + await this.nabeh.sendOtp(this.phones.toWhatsApp(canonical), code); return { success: true, message: 'OTP sent via WhatsApp' }; } - async verifyOtp(tenantSlug: string, phone: string, code: string) { + async verifyOtp(tenantSlug: string, phone: string, code: string, deviceId?: string) { const tenant = await this.resolveTenant(tenantSlug); + // نفس التطبيع بالضبط — وإلا فشل التحقق لمجرد أن المستخدم كتب الرقم + // بصيغة مختلفة قليلاً عن مرة الإرسال (مثال المالك: "01" مقابل "1"). + const canonical = this.phones.normalize(phone, tenant.countryPack); // في وضع التطوير: الرمز الثابت 1234 يمرّ دائماً (تسهيل الاختبار). const devBypass = this.devMode && code === '1234'; if (!devBypass) { - const stored = await this.redis.get(this.otpKey(tenant.id, phone)); + const stored = await this.redis.get(this.otpKey(tenant.id, canonical)); if (!stored || stored !== code) { throw new UnauthorizedException('Invalid or expired OTP code'); } - await this.redis.del(this.otpKey(tenant.id, phone)); + await this.redis.del(this.otpKey(tenant.id, canonical)); } - let user = await this.usersService.findByPhone(tenant.id, phone); + let user = await this.usersService.findByPhone(tenant.id, canonical); if (!user) { - user = await this.usersService.create(tenant.id, phone); + user = await this.usersService.create(tenant.id, canonical); } - return this.issueTokens(user, tenant.id); + return this.issueTokens(user, tenant.id, deviceId); } - async refresh(refreshToken: string) { + /** + * `deviceId` هنا هو الجهاز الذي يطلب **التحديث الآن**، لا الجهاز الأصلي. + * لا نتحقق من تطابقه مع التوكن القديم عمداً: `JwtStrategy` يحرس النقاط + * المحمية بالفعل بالجهاز المرتبط بتوكن الدخول الحالي (docs/17 — D2)؛ + * إعادة تربيط عند كل تحديث تعقيدٌ إضافي بلا فائدة أمنية إضافية هنا. + */ + async refresh(refreshToken: string, deviceId?: string) { let payload: any; try { payload = this.jwtService.verify(refreshToken); @@ -106,15 +112,18 @@ export class AuthService { } const user = await this.usersService.findById(payload.tenant_id, payload.sub); if (!user) throw new UnauthorizedException('User not found'); - return this.issueTokens(user, user.tenant_id); + return this.issueTokens(user, user.tenant_id, deviceId); } - private async issueTokens(user: User, tenantId: string) { + private async issueTokens(user: User, tenantId: string, deviceId?: string) { const base = { sub: user.id, phone: user.phone, role: user.role, tenant_id: tenantId, + // بصمة الجهاز فقط — لا القيمة الخام (docs/17 — D2). التوكن المسروق + // من الشبكة (رغم TLS) لا يعمل من جهاز آخر يفعّل هذه الميزة. + ...(deviceId ? { device_id: this.device.hash(deviceId) } : {}), }; return { access_token: this.jwtService.sign(base), @@ -128,7 +137,8 @@ export class AuthService { // **لماذا مفتاح لكل جلسة لا سرّ ثابت في التطبيق؟** أي سرّ داخل التطبيق // يُستخرج بالهندسة العكسية فيصير التوقيع مسرحية. المفتاح هنا يُولَّد على // السيرفر لكل دخول، فمن يفكّك الـAPK لا يجد شيئاً، ومن يسرق توكناً - // (الـAPI على http حالياً) لا يملك المفتاح فلا يستطيع توقيع سحب. + // (TLS مفعَّل الآن — docs/20؛ هذا دفاع إضافي لا اعتماد على قناة مكشوفة) + // لا يملك المفتاح فلا يستطيع توقيع سحب. signing_key: await this.signing.issue(tenantId, user.id), user, }; diff --git a/backend/src/modules/auth/strategies/jwt.strategy.spec.ts b/backend/src/modules/auth/strategies/jwt.strategy.spec.ts new file mode 100644 index 0000000..a360c21 --- /dev/null +++ b/backend/src/modules/auth/strategies/jwt.strategy.spec.ts @@ -0,0 +1,69 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { JwtStrategy } from './jwt.strategy'; +import { DeviceService } from '../../../common/device/device.service'; + +const PAYLOAD = { sub: 'u1', phone: '962790000000', role: 'rider', tenant_id: 't1' }; + +function reqWith(deviceHeader?: string) { + return { headers: deviceHeader !== undefined ? { 'x-device-id': deviceHeader } : {} }; +} + +function strategyWith(requireDeviceBinding: boolean) { + const config = { get: (k: string) => (k === 'auth.requireDeviceBinding' ? requireDeviceBinding : undefined) }; + return new JwtStrategy(config as any, new DeviceService()); +} + +describe('JwtStrategy — ربط الجلسة بالجهاز (docs/17 D2)', () => { + it('مطفأة افتراضياً: تمرّ بلا ترويسة جهاز إطلاقاً', async () => { + const strategy = strategyWith(false); + const user = await strategy.validate(reqWith(undefined), PAYLOAD); + expect(user.userId).toBe('u1'); + }); + + it('مفعَّلة: توكن يحمل بصمة الجهاز الصحيحة يمرّ', async () => { + const strategy = strategyWith(true); + const device = new DeviceService(); + const payload = { ...PAYLOAD, device_id: device.hash('phone-abc') }; + + const user = await strategy.validate(reqWith('phone-abc'), payload); + expect(user.userId).toBe('u1'); + }); + + it('مفعَّلة: جهاز مختلف عن توكن الدخول يُرفض — التوكن المسروق لا يعمل من جهاز آخر', async () => { + const strategy = strategyWith(true); + const device = new DeviceService(); + const payload = { ...PAYLOAD, device_id: device.hash('victims-phone') }; + + await expect(strategy.validate(reqWith('attackers-phone'), payload)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('مفعَّلة: بلا ترويسة جهاز يُرفض', async () => { + const strategy = strategyWith(true); + const device = new DeviceService(); + const payload = { ...PAYLOAD, device_id: device.hash('phone-abc') }; + + await expect(strategy.validate(reqWith(undefined), payload)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('مفعَّلة: توكن صدر قبل تفعيل الميزة (بلا device_id) يُرفض لا يمرّ بصمت', async () => { + const strategy = strategyWith(true); + await expect(strategy.validate(reqWith('some-device'), PAYLOAD)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('البصمة الخام لا تُقارَن نصّياً — hash فقط', async () => { + const strategy = strategyWith(true); + // لو أرسل المهاجم الـhash نفسه كترويسة (بدل الجهاز الخام) يجب أن يُرفض، + // لأن الاستراتيجية تُطبّق hash() على الترويسة أيضاً قبل المقارنة. + const device = new DeviceService(); + const rawHash = device.hash('phone-abc'); + const payload = { ...PAYLOAD, device_id: rawHash }; + + await expect(strategy.validate(reqWith(rawHash), payload)).rejects.toThrow(UnauthorizedException); + }); +}); diff --git a/backend/src/modules/auth/strategies/jwt.strategy.ts b/backend/src/modules/auth/strategies/jwt.strategy.ts index 98c213e..d0540dc 100644 --- a/backend/src/modules/auth/strategies/jwt.strategy.ts +++ b/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -1,24 +1,50 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; import { PassportStrategy } from '@nestjs/passport'; -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { DeviceService } from '../../../common/device/device.service'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(configService: ConfigService) { + constructor( + private readonly config: ConfigService, + private readonly device: DeviceService, + ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: configService.get('JWT_SECRET') || 'change_me_jwt_secret', + secretOrKey: config.get('JWT_SECRET') || 'change_me_jwt_secret', + // نحتاج الترويسة x-device-id من الطلب نفسه — لا تصل إلا بهذا الخيار. + passReqToCallback: true, }); } - async validate(payload: any) { - return { - userId: payload.sub, - phone: payload.phone, - role: payload.role, - tenantId: payload.tenant_id + /** + * ربط الجلسة بالجهاز (docs/17 — D2). يُنفَّذ هنا — داخل الاستراتيجية — لا + * كحارس منفصل يُضاف يدوياً لكل متحكّم، وإلا نسيان واحد يعني توكناً مسروقاً + * يعمل من أي جهاز على تلك النقطة تحديداً. + * + * **مطفأ افتراضياً** (`AUTH_REQUIRE_DEVICE_BINDING=false`) حتى يرسل فلاتر + * `x-device-id`؛ تفعيله قبل ذلك يقطع كل طلب مصادَق. + */ + async validate(req: any, payload: any) { + if (this.config.get('auth.requireDeviceBinding')) { + const header = req.headers?.['x-device-id']; + if (typeof header !== 'string' || !header) { + throw new UnauthorizedException('device_id_required'); + } + // التوكن لم يحمل بصمة جهاز (صدر قبل تفعيل الميزة) — رسالة واحدة لكل + // الأسباب، لا نُعلّم المهاجم أين الفرق تحديداً. + if (!payload.device_id || payload.device_id !== this.device.hash(header)) { + throw new UnauthorizedException('device_mismatch'); + } + } + + return { + userId: payload.sub, + phone: payload.phone, + role: payload.role, + tenantId: payload.tenant_id, }; } } diff --git a/backend/src/modules/users/admin-users.controller.ts b/backend/src/modules/users/admin-users.controller.ts index 344d16d..479c12b 100644 --- a/backend/src/modules/users/admin-users.controller.ts +++ b/backend/src/modules/users/admin-users.controller.ts @@ -16,6 +16,7 @@ import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; import { PlatformGuard } from '../../common/platform/platform.guard'; +import { PhoneService } from '../../common/phone/phone.service'; const ROLES = ['rider', 'driver', 'dispatcher', 'admin']; @@ -25,6 +26,7 @@ export class AdminUsersController { constructor( private readonly users: UsersService, private readonly tenants: TenantsService, + private readonly phones: PhoneService, ) {} // أدمن **المستأجر** يعيّن دور مستخدم داخل مستأجره هو — هذه ليست نقطة منصة، @@ -47,7 +49,10 @@ export class AdminUsersController { if (!ROLES.includes(body.role)) throw new BadRequestException('invalid role'); const tenant = await this.tenants.resolve(body.tenantSlug); if (!tenant) throw new NotFoundException('unknown tenant'); - const u = await this.users.findByPhone(tenant.id, body.phone); + // المستخدم مخزَّن برقمه المطبَّع (docs/17 — D1) — بحثٌ بالرقم الخام + // يفشل لو كتبه الأدمن بصيغة مختلفة عن التي دخل بها المستخدم أول مرة. + const canonical = this.phones.normalize(body.phone, tenant.countryPack); + const u = await this.users.findByPhone(tenant.id, canonical); if (!u) throw new NotFoundException('user not found (must log in once first)'); await this.users.setRole(tenant.id, u.id, body.role); return this.users.findById(tenant.id, u.id); diff --git a/docs/17-backend-backlog.md b/docs/17-backend-backlog.md index 2fbbfab..a3c3187 100644 --- a/docs/17-backend-backlog.md +++ b/docs/17-backend-backlog.md @@ -66,13 +66,13 @@ --- -## المجموعة D — الأمان والمصادقة +## المجموعة D — الأمان والمصادقة — ✅ منفَّذة -| # | البند | التفصيل | -|---|-------|---------| -| D1 | **تطبيع أرقام الهاتف (JO/EG/SY)** | خصوصاً **مصر**: الناس تكتب `01…` بدل `1…` وتظن المفتاح `2` لا `20`. نحتاج normalize احترافي لكل دولة. | -| D2 | **بصمة الجهاز (device fingerprint)** | تُرسل من فلاتر مع **كل** request وتُربط بالجلسة (نمط سيرو: التوكن المسروق لا يعمل على جهاز آخر). | -| D3 | **HMAC للعمليات الحساسة** | خصوصاً **المدفوعات** — توقيع الطلب. | +| # | البند | الحالة | +|---|-------|--------| +| D1 | **تطبيع أرقام الهاتف (JO/EG/SY)** | ✅ `common/phone/phone.service.ts`. مفتاح قانوني واحد لكل رقم حقيقي — يقبل صفراً محلياً/دولياً/`+`/`00`/بلا صفر، ويرجع دائماً صيغة دولية بلا `+`. يُطبَّق في `send-otp`/`verify-otp`/`platform/users/role`. هجرة `NormalizePhones` تُصحّح حسابات الاختبار الموجودة على السيرفر (بحذر: تتخطّى أي صفٍّ يتصادم ناتجه مع صفٍّ آخر بدل كسر القيد الفريد). | +| D2 | **بصمة الجهاز** | 🟡 **مبنيّة ومطفأة** (`AUTH_REQUIRE_DEVICE_BINDING=false`) حتى يرسل فلاتر `x-device-id`. **مُنفَّذة داخل `JwtStrategy` نفسها لا كحارس منفصل** — يُفرض على كل نقطة محميّة تلقائياً، فلا نقطة منسيّة. توكن الدخول يحمل `hash(deviceId)` لا القيمة الخام؛ توكن مسروق من جهاز آخر يُرفض بمجرد تفعيل العلم. | +| D3 | **HMAC للعمليات الحساسة** | ✅ **منفَّذ ضمن I6** — `SigningService`/`SignatureGuard`، نفس البند لا تكرار. | ---