import { Inject, Injectable, Logger, ServiceUnavailableException, UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import Redis from 'ioredis'; import { REDIS } from '../../common/redis/redis.module'; import { SigningService } from '../../common/signing/signing.service'; import { UsersService } from '../users/users.service'; import { User } from '../users/entities/user.entity'; import { TenantsService } from '../tenants/tenants.service'; import { Tenant } from '../../database/entities/tenant.entity'; import { OtpDispatcher } from '../../integrations/otp/otp-dispatcher.service'; import { PhoneService } from '../../common/phone/phone.service'; import { DeviceService } from '../../common/device/device.service'; @Injectable() export class AuthService { private readonly logger = new Logger('Auth'); constructor( private usersService: UsersService, private jwtService: JwtService, private config: ConfigService, private tenantsService: TenantsService, private readonly otp: OtpDispatcher, private readonly signing: SigningService, private readonly phones: PhoneService, private readonly device: DeviceService, @Inject(REDIS) private readonly redis: Redis, ) {} private async resolveTenant(slugOrId: string): Promise { const tenant = await this.tenantsService.resolve(slugOrId); if (!tenant) throw new UnauthorizedException('Unknown tenant'); // المستأجر المعلَّق يُمنع من الباب (docs/22 — N1): هذه النقطة تخنق // `sendOtp` و`verifyOtp` معاً، فلا يُرسَل رمز أصلاً لمستأجر موقوف. // الطلبات المصادَقة القائمة يقطعها `JwtStrategy` بالتوازي. if (tenant.status !== 'active') throw new UnauthorizedException('tenant_suspended'); return tenant; } // `=== true` لا `!== false`: قيمة مفقودة يجب أن تعني إرسالاً حقيقياً، لا // رمزاً ثابتاً يفتح كل الحسابات (نفس منطق الإعداد في configuration.ts). private get devMode(): boolean { return this.config.get('auth.otpDevMode') === true; } private otpKey(tenantId: string, phone: string): string { return `otp:${tenantId}:${phone}`; } private otpAttemptsKey(tenantId: string, phone: string): string { return `otp:attempts:${tenantId}:${phone}`; } // حدّ محاولات لكل (مستأجر، رقم) — لا لكل IP (docs/17 — D4). حارس الطلبات // العام (ThrottlerGuard) يُبطئ مهاجماً واحداً من عنوان واحد؛ هذا يمنعه حتى // لو دوّر عناوين IP، لأن رمزاً من 4 خانات = 10000 احتمال يُخمَّن في دقائق // بلا هذا الحدّ. نفس النمط المستعمل في payouts.service (I4). private readonly OTP_MAX_ATTEMPTS = 5; private genCode(): string { if (this.devMode) return '1234'; const len = this.config.get('auth.otpLength') ?? 4; let c = ''; for (let i = 0; i < len; i++) c += Math.floor(Math.random() * 10); return c; } 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, canonical), code, 'EX', ttl); // أرقام مخصصة لاختبارات E2E ومراجعي Apple/Google (لا تستهلك رصيد). const isAppReviewAccount = ['+962790000001', '+962790000002'].includes(canonical); if (this.devMode || isAppReviewAccount) { this.logger.log(`OTP (dev/review) tenant=${tenant.slug} phone=${canonical} => ${code}`); return { success: true, message: 'OTP sent (dev)', dev_code: code }; } // إرسال حقيقي — المُوزِّع يختار المزوّد حسب دولة المستأجر مع failover // (docs/17 — D5). فشل السلسلة كاملة = لا رمز يصل، فنُفشل الطلب صراحةً. const sent = await this.otp.send(this.phones.toWhatsApp(canonical), code, { countryPack: tenant.countryPack, }); if (!sent) { throw new ServiceUnavailableException('Failed to send verification code — please try again'); } return { success: true, message: 'OTP sent' }; } 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 isAppReviewAccount = ['+962790000001', '+962790000002'].includes(canonical); const devBypass = (this.devMode || isAppReviewAccount) && code === '1234'; if (!devBypass) { const attemptsKey = this.otpAttemptsKey(tenant.id, canonical); const attempts = await this.redis.incr(attemptsKey); if (attempts === 1) { // نفس عمر الرمز — لا داعي لعدّاد يبقى بعد انتهاء صلاحية الرمز نفسه. await this.redis.expire(attemptsKey, this.config.get('auth.otpTtl') ?? 300); } if (attempts > this.OTP_MAX_ATTEMPTS) { await this.redis.del(this.otpKey(tenant.id, canonical)); // إبطال الرمز فوراً throw new UnauthorizedException('Too many attempts — request a new code'); } 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, canonical), attemptsKey); } let user = await this.usersService.findByPhone(tenant.id, canonical); if (!user) { user = await this.usersService.create(tenant.id, canonical); } return this.issueTokens(user, tenant.id, deviceId); } /** * `deviceId` هنا هو الجهاز الذي يطلب **التحديث الآن**، لا الجهاز الأصلي. * لا نتحقق من تطابقه مع التوكن القديم عمداً: `JwtStrategy` يحرس النقاط * المحمية بالفعل بالجهاز المرتبط بتوكن الدخول الحالي (docs/17 — D2)؛ * إعادة تربيط عند كل تحديث تعقيدٌ إضافي بلا فائدة أمنية إضافية هنا. */ async refresh(refreshToken: string, deviceId?: string) { let payload: any; try { payload = this.jwtService.verify(refreshToken); } catch { throw new UnauthorizedException('Invalid refresh token'); } if (payload.type !== 'refresh') { throw new UnauthorizedException('Not a refresh token'); } 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, deviceId); } 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), refresh_token: this.jwtService.sign( { ...base, type: 'refresh' }, { expiresIn: (this.config.get('jwt.refreshExpires') ?? '30d') as any }, ), // مفتاح توقيع العمليات المالية (docs/17 — I6). يُسلَّم مرة واحدة عند // الدخول ويُخزَّن في flutter_secure_storage. // // **لماذا مفتاح لكل جلسة لا سرّ ثابت في التطبيق؟** أي سرّ داخل التطبيق // يُستخرج بالهندسة العكسية فيصير التوقيع مسرحية. المفتاح هنا يُولَّد على // السيرفر لكل دخول، فمن يفكّك الـAPK لا يجد شيئاً، ومن يسرق توكناً // (TLS مفعَّل الآن — docs/20؛ هذا دفاع إضافي لا اعتماد على قناة مكشوفة) // لا يملك المفتاح فلا يستطيع توقيع سحب. signing_key: await this.signing.issue(tenantId, user.id), user, }; } }