190 lines
9.4 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|