Files
tripz-llc/backend/src/modules/auth/auth.service.ts
T

190 lines
9.4 KiB
TypeScript

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<Tenant> {
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<boolean>('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<number>('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<number>('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<number>('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<string>('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,
};
}
}