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:
Hamza-Ayed
2026-07-17 17:03:56 +03:00
co-authored by Claude Opus 4.8
parent 5cf288b30b
commit 1eb79fee99
15 changed files with 417 additions and 46 deletions
+7 -3
View File
@@ -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);
}
}
+35 -25
View File
@@ -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);