feat: المجموعة E (كشف الاحتيال) + تشفير الهاتف at-rest
E — اتصال ثم إلغاء (من driver_ride_scam عند سيرو): - E1: driver_called_passenger + last_call_at/by/count على الرحلة نفسها لا Redis فقط — حقيقة دائمة تفيد التحقيق بعد انتهاء الرحلة وزوالها من الكاش. يُسجَّل عند call:offer (بدء المكالمة الفعلي) في RealtimeGateway عبر Repository<Trip> مباشرة — لا استيراد TripsModule كاملاً (يستورد RealtimeModule أصلاً، فدورة استيراد) - E2: FraudService.recordCallThenCancel — عدّاد يومي منفصل عن عدّاد الإلغاء العام بالساعة. 3/يوم = إنذار لا يمنع الإلغاء، 6/يوم = حظر مؤقت (طلب المالك حرفياً). يرتبط فقط إن كانت المكالمة خلال 30 دقيقة من الإلغاء تشفير الهاتف at-rest (ملاحظة المالك) — بفهرس أعمى منفصل، لا تشفير ساذج: - AES-256-GCM عشوائي IV يمنع WHERE phone = :x من المطابقة أصلاً (كل تشفير مختلف شكلاً حتى لنفس الرقم) — لو طُبِّق التشفير مباشرة بلا هذا لكسر تسجيل الدخول بالكامل بصمت - users.phone_bidx = HMAC-SHA256(الرقم المطبَّع) — حتمي، يصلح للبحث وللقيد الفريد. القيد انتقل من (tenant_id, phone) إلى (tenant_id, phone_bidx) — القديم كان سيقبل تكرار نفس الرقم الحقيقي فعلياً - findByPhone/create يحسبان البصمة؛ لا نقطة أخرى تقرأ phone بمساواة خام - هجرة EncryptPhone تُشفّر الصفوف القائمة فوراً وتبني البصمة بأثر رجعي — استثناء متعمَّد من تسامح decrypt() المعتاد (الهاتف عمود بحث يومي، خطره كنص صافٍ أعلى من name) - يعتمد على تطبيع D1: بصمة حتمية لنفس *النص*، فلا بد أن يصل الرقم مطبَّعاً قبلها وإلا عاد نفس الرقم بصيغتين ليصير رقمين مرة أخرى هجرات: TripCallSignal · EncryptPhone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1eb79fee99
commit
253fbb4b2a
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* إشارة المكالمة على الرحلة (docs/17 — E1/E2؛ مكافئ isDriverCallPassenger
|
||||||
|
* عند سيرو). تُخزَّن دائماً على الرحلة نفسها — لا في Redis فقط — لأنها
|
||||||
|
* حقيقة تفيد التحقيق حتى بعد زوال حالة الرحلة من الكاش.
|
||||||
|
*/
|
||||||
|
export class TripCallSignal1721890000000 implements MigrationInterface {
|
||||||
|
public async up(q: QueryRunner): Promise<void> {
|
||||||
|
await q.query(
|
||||||
|
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS driver_called_passenger boolean NOT NULL DEFAULT false`,
|
||||||
|
);
|
||||||
|
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS last_call_at timestamptz`);
|
||||||
|
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS last_call_by varchar`);
|
||||||
|
await q.query(
|
||||||
|
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS call_count integer NOT NULL DEFAULT 0`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(q: QueryRunner): Promise<void> {
|
||||||
|
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS call_count`);
|
||||||
|
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS last_call_by`);
|
||||||
|
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS last_call_at`);
|
||||||
|
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS driver_called_passenger`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
import { encrypt, blindIndex } from '../../common/crypto/crypto.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تشفير الهاتف at-rest + فهرس أعمى للبحث (docs/16، docs/17 — D1 إضافة).
|
||||||
|
*
|
||||||
|
* **لماذا فهرس منفصل**: `phone` بعد التشفير غير قابل للبحث بالمساواة إطلاقاً
|
||||||
|
* — AES-256-GCM يستعمل IV عشوائياً لكل عملية، فنفس الرقم الحقيقي ينتج شيفرة
|
||||||
|
* مختلفة كل مرة. القيد الفريد ونقاط `WHERE phone = …` (تسجيل الدخول بأكمله)
|
||||||
|
* ينتقلان إلى `phone_bidx` — بصمة حتمية (HMAC) لا تكشف الرقم لكنها تكرّر
|
||||||
|
* نفسها لنفس الرقم دائماً.
|
||||||
|
*
|
||||||
|
* تُشفَّر الصفوف القائمة في نفس الهجرة (لا تُترك نصّاً صافياً) — الحماية
|
||||||
|
* التلقائية عبر `decrypt()` المتسامح كانت ستُبقيها صافية للأبد بلا هذا.
|
||||||
|
*/
|
||||||
|
export class EncryptPhone1721900000000 implements MigrationInterface {
|
||||||
|
public async up(q: QueryRunner): Promise<void> {
|
||||||
|
await q.query(`ALTER TABLE tripz_users ADD COLUMN IF NOT EXISTS phone_bidx varchar`);
|
||||||
|
|
||||||
|
const rows: { id: string; phone: string }[] = await q.query(
|
||||||
|
`SELECT id, phone FROM tripz_users`,
|
||||||
|
);
|
||||||
|
for (const row of rows) {
|
||||||
|
// تسامح إعادة التشغيل الجزئي: لا نُشفّر قيمة مشفّرة أصلاً مرة أخرى.
|
||||||
|
const alreadyEncrypted = row.phone?.startsWith('v1:');
|
||||||
|
const canonicalPhone = alreadyEncrypted ? null : row.phone;
|
||||||
|
const bidx = blindIndex(canonicalPhone ?? row.phone);
|
||||||
|
|
||||||
|
if (alreadyEncrypted) {
|
||||||
|
await q.query(`UPDATE tripz_users SET phone_bidx = $1 WHERE id = $2`, [bidx, row.id]);
|
||||||
|
} else {
|
||||||
|
await q.query(`UPDATE tripz_users SET phone = $1, phone_bidx = $2 WHERE id = $3`, [
|
||||||
|
encrypt(row.phone),
|
||||||
|
bidx,
|
||||||
|
row.id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await q.query(`ALTER TABLE tripz_users ALTER COLUMN phone_bidx SET NOT NULL`);
|
||||||
|
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone"`);
|
||||||
|
await q.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone_bidx"
|
||||||
|
ON tripz_users (tenant_id, phone_bidx)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(q: QueryRunner): Promise<void> {
|
||||||
|
// لا رجوع لفكّ التشفير جماعياً هنا عمداً — decrypt() يبقى متاحاً عند
|
||||||
|
// القراءة عبر الـORM؛ التراجع عن هذه الهجرة يعني قبول عدم قابلية البحث
|
||||||
|
// إن أُعيد phone فهرساً فريداً وحده، فلا نعيد بناء ذلك القيد تلقائياً.
|
||||||
|
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone_bidx"`);
|
||||||
|
await q.query(`ALTER TABLE tripz_users ALTER COLUMN phone_bidx DROP NOT NULL`);
|
||||||
|
await q.query(`ALTER TABLE tripz_users DROP COLUMN IF EXISTS phone_bidx`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,14 @@ export class FraudService {
|
|||||||
private readonly ARRIVED_MAX_M = 350; // أقصى بعد مقبول عند "وصل"
|
private readonly ARRIVED_MAX_M = 350; // أقصى بعد مقبول عند "وصل"
|
||||||
private readonly FAST_MIN_SEC = 30; // إنهاء أسرع من هذا يُشتبه به
|
private readonly FAST_MIN_SEC = 30; // إنهاء أسرع من هذا يُشتبه به
|
||||||
|
|
||||||
|
// اتصال ثم إلغاء (docs/17 — E2؛ من driver_ride_scam عند سيرو).
|
||||||
|
// نافذة يومية لا ساعة — مقصودة ومنفصلة عن CANCEL_SOFT/HARD: تلك تعدّ كل
|
||||||
|
// إلغاء أياً كان سببه، وهذه تعدّ فقط الإلغاء الذي **سبقته مكالمة** — إشارة
|
||||||
|
// اتفاق خارج التطبيق، لا مجرد كثرة إلغاء.
|
||||||
|
private readonly CALL_CANCEL_WINDOW_MIN = 30; // اتصال خلال آخر نصف ساعة قبل الإلغاء يُحتسب مرتبطاً
|
||||||
|
private readonly CALL_CANCEL_SOFT = 3; // إنذار — طلب المالك حرفياً: «3 إلغاءات/يوم»
|
||||||
|
private readonly CALL_CANCEL_HARD = 6; // التكرار → إجراء (حظر مؤقت)
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(FraudFlag) private readonly repo: Repository<FraudFlag>,
|
@InjectRepository(FraudFlag) private readonly repo: Repository<FraudFlag>,
|
||||||
@Inject(REDIS) private readonly redis: Redis,
|
@Inject(REDIS) private readonly redis: Redis,
|
||||||
@@ -70,6 +78,41 @@ export class FraudService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ربط الاتصال بالإلغاء (docs/17 — E2): اتصال ثم إلغاء سريع = مؤشر اتفاق
|
||||||
|
* خارج التطبيق (الطرفان اتفقا هاتفياً وألغيا ليتجنّبا العمولة). يُنادى
|
||||||
|
* من `TripsService.cancel` مع آخر وقت اتصال على الرحلة (أو null إن لم
|
||||||
|
* تحدث مكالمة، فيخرج فوراً بلا أثر).
|
||||||
|
*
|
||||||
|
* عدّاد **يومي منفصل** عن `recordCancellation` عمداً: التكرار هنا سببه
|
||||||
|
* الاتصال تحديداً لا كثرة الإلغاء العامة.
|
||||||
|
*/
|
||||||
|
async recordCallThenCancel(
|
||||||
|
tenantId: string,
|
||||||
|
subjectType: 'rider' | 'driver',
|
||||||
|
userId: string,
|
||||||
|
tripId: string,
|
||||||
|
lastCallAt: Date | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!lastCallAt) return;
|
||||||
|
const minutesSinceCall = (Date.now() - lastCallAt.getTime()) / 60000;
|
||||||
|
if (minutesSinceCall > this.CALL_CANCEL_WINDOW_MIN) return; // اتصال قديم — غير مرتبط
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const key = `call_cancel:${tenantId}:${userId}:${today}`;
|
||||||
|
const count = await this.redis.incr(key);
|
||||||
|
if (count === 1) await this.redis.expire(key, 26 * 3600); // يتجاوز منتصف الليل بأمان
|
||||||
|
|
||||||
|
if (count >= this.CALL_CANCEL_HARD) {
|
||||||
|
await this.flag(tenantId, subjectType, userId, 'call_then_cancel_hard', { count }, tripId);
|
||||||
|
throw new ForbiddenException('Too many call-then-cancel patterns today — temporarily blocked');
|
||||||
|
}
|
||||||
|
if (count >= this.CALL_CANCEL_SOFT) {
|
||||||
|
// إنذار فقط — لا يمنع الإلغاء نفسه (طلب المالك: 3 = إنذار لا حظر).
|
||||||
|
await this.flag(tenantId, subjectType, userId, 'call_then_cancel_soft', { count }, tripId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* يتحقق من قرب السائق عند "وصل"؛ يبلّغ إن كان بعيداً.
|
* يتحقق من قرب السائق عند "وصل"؛ يبلّغ إن كان بعيداً.
|
||||||
* `driverAt` هو الموقع **الحيّ** من Redis — لا لقطة القاعدة الدورية، وإلا
|
* `driverAt` هو الموقع **الحيّ** من Redis — لا لقطة القاعدة الدورية، وإلا
|
||||||
|
|||||||
@@ -121,6 +121,22 @@ export class Trip {
|
|||||||
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
|
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||||
cancel_fee: number | null;
|
cancel_fee: number | null;
|
||||||
|
|
||||||
|
// ---- إشارة المكالمة (docs/17 — E1؛ مكافئ isDriverCallPassenger عند سيرو) ----
|
||||||
|
// اتصال ثم إلغاء سريع = مؤشر اتفاق خارج التطبيق (E2). نُخزَّن دائماً
|
||||||
|
// (لا نكتفي بـRedis) لأنها حقيقة دائمة عن الرحلة تفيد التحقيق لاحقاً حتى
|
||||||
|
// بعد انتهاء الرحلة وزوال حالتها من Redis.
|
||||||
|
@Column({ default: false })
|
||||||
|
driver_called_passenger: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
|
last_call_at: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
last_call_by: string | null; // rider | driver
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0 })
|
||||||
|
call_count: number;
|
||||||
|
|
||||||
// ---- طوابع دقيقة (docs/17 — B7؛ مقابلها في سيرو بين قوسين) ----
|
// ---- طوابع دقيقة (docs/17 — B7؛ مقابلها في سيرو بين قوسين) ----
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
requested_at: Date;
|
requested_at: Date;
|
||||||
|
|||||||
@@ -354,6 +354,21 @@ export class TripsService {
|
|||||||
// كشف إساءة الإلغاء (قد يرمي عند الحد الصارم)
|
// كشف إساءة الإلغاء (قد يرمي عند الحد الصارم)
|
||||||
await this.fraud.recordCancellation(tenantId, actor, actorUserId, tripId);
|
await this.fraud.recordCancellation(tenantId, actor, actorUserId, tripId);
|
||||||
|
|
||||||
|
// اتصال ثم إلغاء = مؤشر اتفاق خارج التطبيق (docs/17 — E2). last_call_at
|
||||||
|
// ليست في حالة Redis (مكالمة نادرة الحدوث، لا تستحق كاش) — قراءة خفيفة
|
||||||
|
// من القاعدة بعمود واحد.
|
||||||
|
const callRow = await this.trips.findOne({
|
||||||
|
where: { tenant_id: tenantId, id: tripId },
|
||||||
|
select: { last_call_at: true },
|
||||||
|
});
|
||||||
|
await this.fraud.recordCallThenCancel(
|
||||||
|
tenantId,
|
||||||
|
actor,
|
||||||
|
actorUserId,
|
||||||
|
tripId,
|
||||||
|
callRow?.last_call_at ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
const fee = await this.cancelFee(tenantId, tripId, snapshot);
|
const fee = await this.cancelFee(tenantId, tripId, snapshot);
|
||||||
const res = await this.trips.update(
|
const res = await this.trips.update(
|
||||||
{ tenant_id: tenantId, id: tripId, status: snapshot.status },
|
{ tenant_id: tenantId, id: tripId, status: snapshot.status },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
|
||||||
import { EncryptedTransformer } from '../../../common/crypto/crypto.util';
|
import { EncryptedTransformer } from '../../../common/crypto/crypto.util';
|
||||||
|
|
||||||
export enum UserRole {
|
export enum UserRole {
|
||||||
@@ -8,7 +8,10 @@ export enum UserRole {
|
|||||||
ADMIN = 'admin',
|
ADMIN = 'admin',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// القيد الحقيقي بعد التشفير (docs/17 — D1 إضافة): `phone` عشوائي التشفير
|
||||||
|
// فلا يصلح فهرساً فريداً؛ `phone_bidx` الحتمي هو من يمنع الازدواج فعلياً.
|
||||||
@Entity('users')
|
@Entity('users')
|
||||||
|
@Index(['tenant_id', 'phone_bidx'], { unique: true })
|
||||||
export class User {
|
export class User {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,9 +19,21 @@ export class User {
|
|||||||
@Column({ type: 'uuid' })
|
@Column({ type: 'uuid' })
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
|
|
||||||
@Column()
|
// مشفّر at-rest (AES-256-GCM) — يُفكّ تلقائياً عند القراءة.
|
||||||
|
// **لا يُستعمل في WHERE مباشرة**: IV عشوائي لكل عملية تشفير يعني أن نفس
|
||||||
|
// الرقم ينتج شيفرة مختلفة كل مرة، فأي بحث بالمساواة على هذا العمود يفشل
|
||||||
|
// دائماً. البحث والتفرّد كلاهما عبر `phone_bidx` (docs/16 §"blindIndex").
|
||||||
|
@Column({ transformer: EncryptedTransformer })
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فهرس أعمى حتمي (HMAC-SHA256) — نفس الرقم الحقيقي ينتج نفس القيمة دائماً،
|
||||||
|
* فيصلح للبحث `WHERE` وللقيد الفريد `(tenant_id, phone_bidx)`. لا يكشف
|
||||||
|
* الرقم (اتجاه واحد)، وهو المفتاح الفعلي لا `phone` (docs/17 — D1 إضافة).
|
||||||
|
*/
|
||||||
|
@Column()
|
||||||
|
phone_bidx: string;
|
||||||
|
|
||||||
// مشفّر at-rest (AES-256-GCM) — يُفكّ تلقائياً عند القراءة
|
// مشفّر at-rest (AES-256-GCM) — يُفكّ تلقائياً عند القراءة
|
||||||
@Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer })
|
@Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer })
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { newDb } from 'pg-mem';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { User } from './entities/user.entity';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { blindIndex, decrypt } from '../../common/crypto/crypto.util';
|
||||||
|
|
||||||
|
const TENANT_A = '11111111-1111-1111-1111-111111111111';
|
||||||
|
const TENANT_B = '22222222-2222-2222-2222-222222222222';
|
||||||
|
|
||||||
|
function fakeCache() {
|
||||||
|
return { wrap: jest.fn(async (_k: string, _t: number, loader: () => any) => loader()), set: jest.fn() } as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UsersService — الهاتف مشفَّر at-rest (docs/16، docs/17 D1)', () => {
|
||||||
|
let ds: DataSource;
|
||||||
|
let users: UsersService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const db = newDb({ autoCreateForeignKeyIndices: true });
|
||||||
|
db.public.registerFunction({ name: 'version', returns: 'text' as any, implementation: () => 'pg-mem' });
|
||||||
|
db.public.registerFunction({
|
||||||
|
name: 'current_database',
|
||||||
|
returns: 'text' as any,
|
||||||
|
implementation: () => 'tripz',
|
||||||
|
});
|
||||||
|
db.registerExtension('uuid-ossp', (schema) =>
|
||||||
|
schema.registerFunction({
|
||||||
|
name: 'uuid_generate_v4',
|
||||||
|
returns: 'uuid' as any,
|
||||||
|
implementation: () => randomUUID(),
|
||||||
|
impure: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await db.public.none(`CREATE EXTENSION "uuid-ossp"`);
|
||||||
|
|
||||||
|
ds = (await db.adapters.createTypeormDataSource({
|
||||||
|
type: 'postgres',
|
||||||
|
entities: [User],
|
||||||
|
entityPrefix: 'tripz_',
|
||||||
|
})) as DataSource;
|
||||||
|
await ds.initialize();
|
||||||
|
await ds.synchronize();
|
||||||
|
|
||||||
|
users = new UsersService(ds.getRepository(User), fakeCache());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (ds?.isInitialized) await ds.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('يقرأ رقماً مفكوك التشفير رغم أنه مخزَّن مشفّراً', async () => {
|
||||||
|
const created = await users.create(TENANT_A, '962790000000');
|
||||||
|
expect(created.phone).toBe('962790000000'); // الكائن المُعاد فوراً واضح
|
||||||
|
|
||||||
|
const fetched = await users.findById(TENANT_A, created.id);
|
||||||
|
expect(fetched!.phone).toBe('962790000000'); // وكذلك بعد إعادة القراءة
|
||||||
|
});
|
||||||
|
|
||||||
|
it('العمود على القاعدة مشفّر فعلاً — ليس نصّاً صافياً', async () => {
|
||||||
|
const created = await users.create(TENANT_A, '962790000000');
|
||||||
|
|
||||||
|
const raw = await ds.query(`SELECT phone FROM tripz_users WHERE id = $1`, [created.id]);
|
||||||
|
expect(raw[0].phone).not.toBe('962790000000');
|
||||||
|
expect(raw[0].phone).toMatch(/^v1:/); // صيغة common/crypto/crypto.util
|
||||||
|
expect(decrypt(raw[0].phone)).toBe('962790000000'); // لكنه قابل للفكّ
|
||||||
|
});
|
||||||
|
|
||||||
|
it('نفس الرقم يُعطي نفس الفهرس الأعمى دائماً — والبحث يعمل رغم التشفير العشوائي', async () => {
|
||||||
|
const created = await users.create(TENANT_A, '962790000000');
|
||||||
|
|
||||||
|
const found = await users.findByPhone(TENANT_A, '962790000000');
|
||||||
|
expect(found?.id).toBe(created.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('الفهرس الأعمى نفسه لا يكشف الرقم — بصمة أحادية الاتجاه', async () => {
|
||||||
|
const created = await users.create(TENANT_A, '962790000000');
|
||||||
|
const raw = await ds.query(`SELECT phone_bidx FROM tripz_users WHERE id = $1`, [created.id]);
|
||||||
|
|
||||||
|
expect(raw[0].phone_bidx).toBe(blindIndex('962790000000'));
|
||||||
|
expect(raw[0].phone_bidx).not.toContain('790000000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('رقم مختلف لا يتصادف مع فهرس رقم آخر', async () => {
|
||||||
|
await users.create(TENANT_A, '962790000000');
|
||||||
|
expect(await users.findByPhone(TENANT_A, '962790000001')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('القيد الفريد يحمي من ازدواج نفس الرقم داخل المستأجر الواحد', async () => {
|
||||||
|
await users.create(TENANT_A, '962790000000');
|
||||||
|
await expect(users.create(TENANT_A, '962790000000')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('نفس الرقم تحت مستأجرين مختلفين = مستخدمان منفصلان (عزل)', async () => {
|
||||||
|
const a = await users.create(TENANT_A, '962790000000');
|
||||||
|
const b = await users.create(TENANT_B, '962790000000');
|
||||||
|
expect(a.id).not.toBe(b.id);
|
||||||
|
|
||||||
|
expect((await users.findByPhone(TENANT_A, '962790000000'))!.id).toBe(a.id);
|
||||||
|
expect((await users.findByPhone(TENANT_B, '962790000000'))!.id).toBe(b.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findOrCreate لا يُنشئ نسخة ثانية لنفس الرقم', async () => {
|
||||||
|
const first = await users.findOrCreate(TENANT_A, '962790000000');
|
||||||
|
const second = await users.findOrCreate(TENANT_A, '962790000000');
|
||||||
|
expect(first.id).toBe(second.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { User, UserRole } from './entities/user.entity';
|
import { User, UserRole } from './entities/user.entity';
|
||||||
import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service';
|
import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service';
|
||||||
|
import { blindIndex } from '../../common/crypto/crypto.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
@@ -12,8 +13,14 @@ export class UsersService {
|
|||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `phone` مشفّر عشوائي at-rest — `WHERE phone = :x` لا يطابق أبداً حتى مع
|
||||||
|
* نفس الرقم (IV مختلف كل مرة). البحث دائماً عبر `phone_bidx` الحتمي.
|
||||||
|
*/
|
||||||
async findByPhone(tenantId: string, phone: string): Promise<User | null> {
|
async findByPhone(tenantId: string, phone: string): Promise<User | null> {
|
||||||
return this.userRepository.findOne({ where: { tenant_id: tenantId, phone } });
|
return this.userRepository.findOne({
|
||||||
|
where: { tenant_id: tenantId, phone_bidx: blindIndex(phone) },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(tenantId: string, id: string): Promise<User | null> {
|
async findById(tenantId: string, id: string): Promise<User | null> {
|
||||||
@@ -21,7 +28,12 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(tenantId: string, phone: string, role: string = 'rider'): Promise<User> {
|
async create(tenantId: string, phone: string, role: string = 'rider'): Promise<User> {
|
||||||
const user = this.userRepository.create({ tenant_id: tenantId, phone, role: role as UserRole });
|
const user = this.userRepository.create({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
phone,
|
||||||
|
phone_bidx: blindIndex(phone),
|
||||||
|
role: role as UserRole,
|
||||||
|
});
|
||||||
return this.userRepository.save(user);
|
return this.userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import {
|
|||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import { DriversService } from '../modules/drivers/drivers.service';
|
import { DriversService } from '../modules/drivers/drivers.service';
|
||||||
|
import { Trip } from '../modules/trips/entities/trip.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* البوابة الحية (Socket.IO) — docs/09. مصادقة JWT + غرف مُنطَّقة بالمستأجر،
|
* البوابة الحية (Socket.IO) — docs/09. مصادقة JWT + غرف مُنطَّقة بالمستأجر،
|
||||||
@@ -25,6 +28,7 @@ export class RealtimeGateway implements OnGatewayConnection {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly jwt: JwtService,
|
private readonly jwt: JwtService,
|
||||||
private readonly drivers: DriversService,
|
private readonly drivers: DriversService,
|
||||||
|
@InjectRepository(Trip) private readonly trips: Repository<Trip>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
handleConnection(client: Socket) {
|
handleConnection(client: Socket) {
|
||||||
@@ -91,9 +95,34 @@ export class RealtimeGateway implements OnGatewayConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- إشارات WebRTC (مكالمة مجانية بين الطرفين عبر غرفة الرحلة) ----
|
// ---- إشارات WebRTC (مكالمة مجانية بين الطرفين عبر غرفة الرحلة) ----
|
||||||
|
/**
|
||||||
|
* `offer` هو حدث **بدء الاتصال** فعلياً (answer/ice/end إشارات تفاوض
|
||||||
|
* لاحقة على نفس المكالمة) — لذا هنا فقط نُسجّل إشارة الاحتيال (docs/17 —
|
||||||
|
* E1). الكتابة على القاعدة مباشرة لا عبر Redis: تكرار المكالمات لا يقارن
|
||||||
|
* بتكرار نبضات الموقع، فلا حاجة لتجميع (نمط H) هنا.
|
||||||
|
*/
|
||||||
@SubscribeMessage('call:offer')
|
@SubscribeMessage('call:offer')
|
||||||
onCallOffer(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
|
async onCallOffer(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
|
||||||
this.relay(c, 'call:offer', b);
|
this.relay(c, 'call:offer', b);
|
||||||
|
const u = c.data.user;
|
||||||
|
if (!u || !b?.tripId) return;
|
||||||
|
try {
|
||||||
|
await this.trips
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update(Trip)
|
||||||
|
.set({
|
||||||
|
last_call_at: () => 'now()',
|
||||||
|
last_call_by: u.role,
|
||||||
|
call_count: () => 'call_count + 1',
|
||||||
|
...(u.role === 'driver' ? { driver_called_passenger: true } : {}),
|
||||||
|
})
|
||||||
|
.where('tenant_id = :tenantId AND id = :tripId', { tenantId: u.tenantId, tripId: b.tripId })
|
||||||
|
.execute();
|
||||||
|
} catch (e: any) {
|
||||||
|
// تسجيل الإشارة لا يجوز أن يقطع المكالمة نفسها — أُعيد المحاولة لاحقاً
|
||||||
|
// غير ممكنة هنا، فنكتفي باللوغ.
|
||||||
|
this.logger.warn(`call signal record failed: ${e?.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('call:answer')
|
@SubscribeMessage('call:answer')
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { RealtimeGateway } from './realtime.gateway';
|
import { RealtimeGateway } from './realtime.gateway';
|
||||||
import { DriversModule } from '../modules/drivers/drivers.module';
|
import { DriversModule } from '../modules/drivers/drivers.module';
|
||||||
|
import { Trip } from '../modules/trips/entities/trip.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
DriversModule,
|
DriversModule,
|
||||||
|
// تسجيل الكيان مباشرة لا وحدة TripsModule كاملة — TripsModule تستورد
|
||||||
|
// RealtimeModule أصلاً، فاستيراد معاكس يصنع دورة (docs/17 — E1).
|
||||||
|
TypeOrmModule.forFeature([Trip]),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
|
|||||||
+10
-6
@@ -17,15 +17,19 @@ name: string;
|
|||||||
```
|
```
|
||||||
|
|
||||||
## المطبّق حالياً
|
## المطبّق حالياً
|
||||||
- `users.name` · `drivers.vehicle_plate`.
|
- `users.name` · `users.phone` · `drivers.vehicle_plate`.
|
||||||
|
|
||||||
## الحقول القابلة للبحث (مثل الهاتف) — الحل الصحيح
|
## الحقول القابلة للبحث (مثل الهاتف) — ✅ منفَّذ (docs/17 — D1 إضافة)
|
||||||
تشفير GCM عشوائي **يمنع المطابقة بالتساوي** (كل تشفير مختلف). للبحث نستخدم **فهرس أعمى (blind index)**: عمود إضافي = `HMAC-SHA256(القيمة)` حتمي للبحث + العمود المشفّر للتخزين. مخطّط عند بناء بحث الهاتف/الهوية.
|
تشفير GCM عشوائي **يمنع المطابقة بالتساوي** (كل تشفير مختلف) — `WHERE phone = :x` لا يطابق أبداً حتى مع نفس الرقم. الحل: **فهرس أعمى (blind index)** — `users.phone_bidx = HMAC-SHA256(canonicalPhone)` حتمي، عمود إضافي غير مشفَّر (بصمة أحادية الاتجاه، لا تكشف الرقم). كل بحث وتفرّد على الهاتف يمرّ عبره لا عبر `phone`:
|
||||||
|
- `UsersService.findByPhone` يحسب `blindIndex(phone)` ويبحث به.
|
||||||
|
- القيد الفريد `(tenant_id, phone_bidx)` — لا `(tenant_id, phone)` (ذاك كان سيقبل تكرار نفس الرقم الحقيقي لأن كل تشفير مختلف شكلاً).
|
||||||
|
- **يعتمد على تطبيع D1**: البصمة حتمية لنفس *النص*، فلا بد أن يصل `phone` مطبَّعاً (`common/phone/phone.service.ts`) قبل الوصول لهذا الكود — وإلا اعتُبر نفس الرقم بصيغتين مختلفتين رقمين مختلفين مرة أخرى.
|
||||||
|
|
||||||
## الميغريشن (صحيح ومتوافق)
|
## الميغريشن (صحيح ومتوافق)
|
||||||
- **لا تغيير مخطّط:** الأعمدة `varchar` تتّسع للنص المشفّر (base64). لا هجرة لازمة لتحويل النوع.
|
- **لا تغيير مخطّط لـ`varchar`:** الأعمدة تتّسع للنص المشفّر (base64). لا هجرة لتحويل النوع.
|
||||||
- **تسامح انتقالي:** `decrypt` يُرجع النص القديم غير المشفّر كما هو (يبدأ بلا `v1:`) — فلا تنكسر القراءة على بيانات قديمة؛ والكتابة الجديدة تُشفّر.
|
- **تسامح انتقالي:** `decrypt` يُرجع النص القديم غير المشفّر كما هو (يبدأ بلا `v1:`) — فلا تنكسر القراءة على بيانات قديمة.
|
||||||
- **عند نقل بيانات حقيقية من سيرو:** تُقرأ مفكوكةً من سيرو ثم تُكتب فتُشفَّر عندنا تلقائياً.
|
- **الهاتف استثناء متعمَّد لهذا التسامح**: هجرة `EncryptPhone` تُشفّر الصفوف القائمة **فوراً** (لا تتركها صافية للأبد بالاعتماد على `decrypt` المتسامح) — لأن `phone` عمود بحث نشط يومياً (كل تسجيل دخول)، ونصّه الصافي في القاعدة أخطر من `name` (نادراً ما يُقرأ مباشرة كنص خام). نفس الهجرة تبني `phone_bidx` بأثر رجعي لكل صف قائم.
|
||||||
|
- **عند نقل بيانات حقيقية من سيرو:** تُقرأ مفكوكةً من سيرو ثم تُكتب فتُشفَّر عندنا تلقائياً — ويجب تمريرها عبر `PhoneService.normalize` أولاً قبل الكتابة، وإلا فسدت البصمة.
|
||||||
|
|
||||||
## ما يُشفَّر مستقبلاً
|
## ما يُشفَّر مستقبلاً
|
||||||
وثائق السائق وأرقام الهوية/الرخصة (وحدة الوثائق)، ووجهات السحب الحساسة، وأي PII يُضاف — بنفس المحوّل.
|
وثائق السائق وأرقام الهوية/الرخصة (وحدة الوثائق)، ووجهات السحب الحساسة، وأي PII يُضاف — بنفس المحوّل.
|
||||||
|
|||||||
@@ -76,12 +76,12 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## المجموعة E — كشف الاحتيال (من `driver_ride_scam`)
|
## المجموعة E — كشف الاحتيال (من `driver_ride_scam`) — ✅ منفَّذة
|
||||||
|
|
||||||
| # | البند | التفصيل |
|
| # | البند | الحالة |
|
||||||
|---|-------|---------|
|
|---|-------|--------|
|
||||||
| E1 | **تسجيل زر الاتصال** | `isDriverCallPassenger` لكل رحلة (سيرو). |
|
| E1 | **تسجيل زر الاتصال** | ✅ `driver_called_passenger` + `last_call_at`/`last_call_by`/`call_count` على الرحلة نفسها (لا Redis فقط — حقيقة دائمة تفيد التحقيق بعد انتهاء الرحلة). يُسجَّل عند `call:offer` في `RealtimeGateway` — لحظة **بدء** الاتصال الفعلية، لا `answer`/`ice`/`end` (إشارات تفاوض لاحقة لنفس المكالمة). |
|
||||||
| E2 | **ربط الاتصال بالإلغاء** | اتصال ثم إلغاء = مؤشر اتفاق خارج التطبيق. **3 إلغاءات/يوم → إنذار**؛ التكرار → إجراء. |
|
| E2 | **ربط الاتصال بالإلغاء** | ✅ `FraudService.recordCallThenCancel` — عدّاد **يومي منفصل** عن عدّاد الإلغاء العام (ذاك بالساعة ويعدّ كل إلغاء؛ هذا باليوم ويعدّ فقط الإلغاء **المسبوق باتصال خلال 30 دقيقة**). **3/يوم = إنذار (لا يمنع الإلغاء) — 6/يوم = حظر مؤقت**، طبق طلب المالك حرفياً. يُنادى من `TripsService.cancel` بقراءة خفيفة لعمود واحد قبل التحديث. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user