import { BadRequestException, HttpException, HttpStatus, Injectable, Logger, ServiceUnavailableException, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { createHash, randomInt } from 'crypto'; import { IsNull, MoreThan, Repository } from 'typeorm'; import { isValidIranMobile, normalizeIranMobile, } from '../../common/utils/iran-mobile.util'; import { hashPassword, verifyPassword, } from '../../common/utils/password.util'; import { serializeUser } from '../../common/utils/user-serialize.util'; import { AuthOtpEntity } from '../../entities/auth-otp.entity'; import { UserEntity, UserRole } from '../../entities/user.entity'; import { JwtPayload } from './auth.types'; import { LoginPasswordDto } from './dto/login-password.dto'; import { RequestOtpDto } from './dto/request-otp.dto'; import { SetPasswordDto } from './dto/set-password.dto'; import { VerifyOtpDto } from './dto/verify-otp.dto'; import { SmsService } from './sms.service'; type RequestOtpResponse = { expiresInSeconds: number; otpCode?: string; }; type AuthSessionResponse = { accessToken: string; user: ReturnType; }; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); private readonly otpTtlSeconds: number; private readonly maxAttempts: number; private readonly otpRequestCooldownSeconds: number; private readonly otpRequestMaxPerHour: number; constructor( private readonly config: ConfigService, private readonly jwt: JwtService, private readonly sms: SmsService, @InjectRepository(AuthOtpEntity) private readonly otps: Repository, @InjectRepository(UserEntity) private readonly users: Repository, ) { this.otpTtlSeconds = this.readPositiveInt('OTP_TTL_SECONDS', 120); this.maxAttempts = this.readPositiveInt('OTP_MAX_ATTEMPTS', 5); this.otpRequestCooldownSeconds = this.readPositiveInt( 'OTP_REQUEST_COOLDOWN_SECONDS', 60, ); this.otpRequestMaxPerHour = this.readPositiveInt( 'OTP_REQUEST_MAX_PER_HOUR', 5, ); } async requestOtp(dto: RequestOtpDto): Promise { const mobile = this.normalizeMobile(dto.mobile); await this.assertOtpRequestAllowed(mobile); const code = this.generateOtpCode(); const expiresAt = new Date(Date.now() + this.otpTtlSeconds * 1000); await this.otps.update( { mobile, consumedAt: IsNull() }, { consumedAt: new Date() }, ); await this.otps.save( this.otps.create({ mobile, codeHash: this.hashOtp(mobile, code), expiresAt, }), ); const returnOtpInResponse = this.config.get('AUTH_RETURN_OTP') === 'true'; if (!returnOtpInResponse) { if (!this.sms.isConfigured()) { throw new BadRequestException( 'SMS is not configured. Set SMS_USERNAME, SMS_PASSWORD (or SMS_API_KEY), and SMS_SENDER.', ); } const result = await this.sms.sendSmsDetailed( mobile, `کد ورود منتایم: ${code}`, ); if (!result.ok) { const detail = result.reason ? ` (${result.reason})` : ''; this.logger.warn( `OTP SMS failed for ${this.maskMobile(mobile)} transport=${result.transport} reason=${result.reason}`, ); throw new ServiceUnavailableException( `Could not send SMS. Please try again later.${detail}`, ); } } const response: RequestOtpResponse = { expiresInSeconds: this.otpTtlSeconds, }; if (returnOtpInResponse) { response.otpCode = code; } return response; } async verifyOtp(dto: VerifyOtpDto): Promise { const mobile = this.normalizeMobile(dto.mobile); const code = String(dto.code ?? '').trim(); if (!/^\d{4}$/.test(code)) { throw new BadRequestException('Invalid OTP code'); } const otp = await this.otps.findOne({ where: { mobile, consumedAt: IsNull(), expiresAt: MoreThan(new Date()), }, order: { createdAt: 'DESC' }, }); if (!otp) { throw new UnauthorizedException('OTP expired or not requested'); } if (otp.attempts >= this.maxAttempts) { throw new UnauthorizedException('Too many OTP attempts'); } if (otp.codeHash !== this.hashOtp(mobile, code)) { otp.attempts += 1; await this.otps.save(otp); throw new UnauthorizedException('Invalid OTP code'); } otp.consumedAt = new Date(); await this.otps.save(otp); const user = await this.findOrCreateCustomer(mobile); return this.issueSession(user); } async loginWithPassword(dto: LoginPasswordDto): Promise { const mobile = this.normalizeMobile(dto.mobile); const user = await this.users .createQueryBuilder('user') .addSelect('user.passwordHash') .where('user.mobile = :mobile', { mobile }) .getOne(); if (!user || !user.isActive) { throw new UnauthorizedException('Invalid mobile or password'); } if (!user.passwordHash) { throw new UnauthorizedException( 'Password is not set. Sign in with OTP first, then set a password in your profile.', ); } const valid = await verifyPassword(dto.password, user.passwordHash); if (!valid) { throw new UnauthorizedException('Invalid mobile or password'); } return this.issueSession(user); } async setPassword(userId: string, dto: SetPasswordDto) { if (dto.password !== dto.confirmPassword) { throw new BadRequestException('Password confirmation does not match'); } const user = await this.users .createQueryBuilder('user') .addSelect('user.passwordHash') .where('user.id = :id', { id: userId }) .getOne(); if (!user || !user.isActive) { throw new UnauthorizedException('User not found'); } user.passwordHash = await hashPassword(dto.password); const saved = await this.users.save(user); return serializeUser(saved); } async me(userId: string) { const user = await this.users .createQueryBuilder('user') .addSelect('user.passwordHash') .where('user.id = :id', { id: userId }) .getOne(); if (!user) { throw new UnauthorizedException('User not found'); } return serializeUser(user); } private async issueSession(user: UserEntity): Promise { const full = await this.users .createQueryBuilder('user') .addSelect('user.passwordHash') .where('user.id = :id', { id: user.id }) .getOne(); const profile = full ?? user; const payload: JwtPayload = { sub: profile.id, mobile: profile.mobile, role: profile.role, }; return { accessToken: await this.jwt.signAsync(payload), user: serializeUser(profile), }; } private async findOrCreateCustomer(mobile: string): Promise { const existing = await this.users.findOne({ where: { mobile } }); if (existing) { if (!existing.isActive) { throw new UnauthorizedException('User is inactive'); } if (this.isConfiguredSuperAdminMobile(mobile) && existing.role !== UserRole.ADMIN) { existing.role = UserRole.ADMIN; return this.users.save(existing); } return existing; } return this.users.save( this.users.create({ mobile, fullName: null, role: this.isConfiguredSuperAdminMobile(mobile) ? UserRole.ADMIN : UserRole.CUSTOMER, isActive: true, }), ); } private normalizeMobile(mobile: string): string { const normalized = normalizeIranMobile(mobile); if (!isValidIranMobile(normalized)) { throw new BadRequestException('Invalid mobile number'); } return normalized; } private generateOtpCode(): string { return String(randomInt(1000, 10000)); } private hashOtp(mobile: string, code: string): string { const pepper = this.config.get('OTP_HASH_SECRET') ?? this.config.get('JWT_SECRET') ?? 'montime-dev-secret-change-me'; return createHash('sha256') .update(`${mobile}:${code}:${pepper}`) .digest('hex'); } private isConfiguredSuperAdminMobile(mobile: string): boolean { const configured = this.config.get('SUPER_ADMIN_MOBILE'); return configured ? this.normalizeMobile(configured) === mobile : false; } private async assertOtpRequestAllowed(mobile: string): Promise { const latest = await this.otps.findOne({ where: { mobile }, order: { createdAt: 'DESC' }, }); if (latest) { const elapsedMs = Date.now() - latest.createdAt.getTime(); const cooldownMs = this.otpRequestCooldownSeconds * 1000; if (elapsedMs < cooldownMs) { const retryAfterSeconds = Math.ceil( (cooldownMs - elapsedMs) / 1000, ); throw new HttpException( `Please wait ${retryAfterSeconds} seconds before requesting a new code`, HttpStatus.TOO_MANY_REQUESTS, ); } } const hourAgo = new Date(Date.now() - 60 * 60 * 1000); const recentCount = await this.otps.count({ where: { mobile, createdAt: MoreThan(hourAgo) }, }); if (recentCount >= this.otpRequestMaxPerHour) { throw new HttpException( 'Too many OTP requests. Please try again later', HttpStatus.TOO_MANY_REQUESTS, ); } } private maskMobile(mobile: string): string { if (mobile.length <= 6) { return '***'; } return `${mobile.slice(0, 4)}****${mobile.slice(-2)}`; } private readPositiveInt(key: string, fallback: number): number { const raw = this.config.get(key); const parsed = raw ? Number.parseInt(raw, 10) : NaN; return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } }