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
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user