feat: المجموعة D — تطبيع الهاتف + ربط الجلسة بالجهاز
D1 — تطبيع أرقام الهاتف (JO/EG/SY): - common/phone/phone.service.ts: مفتاح قانوني واحد لكل رقم حقيقي — يقبل صفراً محلياً/دولياً/+/00/بلا صفر ويرجع صيغة دولية موحّدة بلا + - يعالج لبس مصر تحديداً الذي ذكره المالك: "01012345678" يُطبَّع بمفتاح 20 الصحيح لا "2" — الخوارزمية عامة فلا تحتاج معرفة اللبس، فقط تحذف الصفر البادئ وتضيف مفتاح الدولة الحقيقي - send-otp و verify-otp و platform/users/role تمرّ كلها بالتطبيع الآن؛ كانت تخزّن/تبحث بالرقم الخام كما كُتب — رقم واحد بصيغتين = حسابان - هجرة NormalizePhones تُصحّح حسابات الاختبار الموجودة على السيرفر، بحذر: تتخطّى أي تصادم بدل كسر القيد الفريد (tenant_id, phone) - أُزيل config.callingCodes المكرّر — مصدر واحد للحقيقة D2 — ربط الجلسة بالجهاز (نمط سيرو): **مبنيّ ومطفأ** (AUTH_REQUIRE_DEVICE_BINDING=false) حتى يرسل فلاتر x-device-id. منفَّذ داخل JwtStrategy.validate نفسها (passReqToCallback) لا كحارس يُضاف يدوياً لكل متحكّم — فلا نقطة محميّة يمكن نسيانها. التوكن يحمل hash(deviceId) لا القيمة الخام. verifyOtp/refresh يمرّران x-device-id من المتحكّم عند إصدار التوكن. D3 — HMAC: منفَّذ فعلاً ضمن I6 (SigningService/SignatureGuard)، لا تكرار. خطأ ضبطته قبل الدفع: كتبت مفتاح `auth:` ثانياً في configuration.ts — كائنات JS تسمح بمفاتيح مكرّرة والأخير يطغى، فكان سيمحو otpDevMode/otpTtl/ otpLength بالكامل. دُمج في الكتلة الأصلية بدل مفتاح جديد. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5cf288b30b
commit
1eb79fee99
@@ -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, // عالمي — تخزين ملفات الوثائق
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { DeviceService } from './device.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [DeviceService],
|
||||
exports: [DeviceService],
|
||||
})
|
||||
export class DeviceModule {}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PhoneService } from './phone.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PhoneService],
|
||||
exports: [PhoneService],
|
||||
})
|
||||
export class PhoneModule {}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, DialPlan> = {
|
||||
// 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, '');
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>,
|
||||
// ملاحظة: خرائط الاتصال الدولية صارت في common/phone/phone.service.ts
|
||||
// (docs/17 — D1) — مصدر واحد للحقيقة بدل نسختين قد تتباعدان.
|
||||
|
||||
// Gemini (رؤية) — قراءة الوثائق ومطابقة الوجه.
|
||||
gemini: {
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
// لا رجوع — لا سبيل لمعرفة الصيغة الأصلية بعد التطبيع (وقد تكون كانت
|
||||
// بصيغ مختلفة أصلاً). التطبيع أُحادي الاتجاه بطبيعته.
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,12 @@ async function bootstrap() {
|
||||
'Security',
|
||||
);
|
||||
}
|
||||
if (cfg.get<boolean>('auth.requireDeviceBinding') !== true) {
|
||||
Logger.warn(
|
||||
'AUTH_REQUIRE_DEVICE_BINDING=false — التوكن المسروق يعمل من أي جهاز. فعّله بعد أن يرسل فلاتر x-device-id (docs/17 D2).',
|
||||
'Security',
|
||||
);
|
||||
}
|
||||
|
||||
const port = cfg.get<number>('apiPort') ?? 4010;
|
||||
await app.listen(port, '0.0.0.0');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Record<string, string>>('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<number>('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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string>('JWT_SECRET') || 'change_me_jwt_secret',
|
||||
secretOrKey: config.get<string>('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<boolean>('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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user