import { Injectable, BadRequestException, ConflictException, UnauthorizedException, NotFoundException, HttpException, HttpStatus, Logger, OnModuleInit, OnModuleDestroy, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, LessThan, Repository } from 'typeorm'; import * as bcrypt from 'bcrypt'; import * as crypto from 'crypto'; import { VerificationCode } from './entities/verification-code.entity'; import { User } from './entities/user.entity'; import { UsersService } from './users.service'; import { SmsService } from '../notifications/sms.service'; import { OtpMessageKind, VerificationPurpose } from '../common/enums'; import { normalizeIranMobile } from '../common/phone.util'; const CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes const RESEND_COOLDOWN_MS = 60 * 1000; // 60 seconds between requests const MAX_VERIFY_ATTEMPTS = 5; // Abandoned-signup cleanup: drop unverified accounts after this long, scanned // on this interval. const UNVERIFIED_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // hourly /** * One-time SMS code flows for phone ownership. * * Policy (best practice): * - Codes are 6 digits, stored hashed, expire in 5 minutes, allow 5 attempts, * and are rate-limited to one request per minute per (user, purpose). * - LOGIN codes verify a phone for registration completion and passwordless * login; CHANGE_PHONE codes verify a new number from the account page (which * additionally re-authenticates with the current password). */ @Injectable() export class VerificationService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(VerificationService.name); private cleanupTimer: NodeJS.Timeout | null = null; constructor( @InjectRepository(VerificationCode) private readonly codeRepo: Repository, private readonly usersService: UsersService, private readonly smsService: SmsService, ) {} onModuleInit() { this.cleanupTimer = setInterval(() => this.runCleanup(), CLEANUP_INTERVAL_MS); // Run shortly after startup to catch up on anything left over. setTimeout(() => this.runCleanup(), 10_000); } onModuleDestroy() { if (this.cleanupTimer) clearInterval(this.cleanupTimer); } /** Purge expired codes and abandoned (never-verified) signups. */ private async runCleanup() { try { await this.purgeExpired(); const removed = await this.usersService.deleteStaleUnverified(UNVERIFIED_TTL_MS); if (removed > 0) { this.logger.log(`Removed ${removed} abandoned unverified account(s)`); } } catch (err) { this.logger.error(`Cleanup scan failed: ${(err as Error).message}`); } } // ── Login / registration phone verification ─────────── /** * Send a LOGIN code to the user's bound phone. `kind` only selects the SMS * wording (registration vs passwordless login); the stored purpose is LOGIN * for both. */ async issueLoginOtp(user: User, kind: OtpMessageKind = OtpMessageKind.LOGIN) { if (!user.phone) { throw new BadRequestException('Account has no mobile number'); } return this.issue({ userId: user.id, purpose: VerificationPurpose.LOGIN, destination: user.phone, messageKind: kind, }); } /** Verify a LOGIN code; marks the phone verified on success. */ async verifyLoginOtp(user: User, code: string) { await this.consume(user.id, VerificationPurpose.LOGIN, code); if (!user.phoneVerified) { await this.usersService.update(user.id, { phoneVerified: true }); } } // ── Account-page phone change ───────────────────────── async requestPhoneChange( userId: string, rawPhone: string, currentPassword: string, ) { const phone = normalizeIranMobile(rawPhone); if (!phone) { throw new BadRequestException('Invalid mobile number'); } await this.assertPassword(userId, currentPassword); const existing = await this.usersService.findByPhone(phone); if (existing && existing.id !== userId) { throw new ConflictException('Mobile number already in use'); } return this.issue({ userId, purpose: VerificationPurpose.CHANGE_PHONE, destination: phone, messageKind: OtpMessageKind.CHANGE_PHONE, }); } async confirmPhoneChange(userId: string, code: string) { const record = await this.consume(userId, VerificationPurpose.CHANGE_PHONE, code); // Re-check uniqueness at confirm time to close the request→confirm race. const clash = await this.usersService.findByPhone(record.destination); if (clash && clash.id !== userId) { throw new ConflictException('Mobile number already in use'); } await this.usersService.update(userId, { phone: record.destination, phoneVerified: true, }); const user = await this.usersService.findById(userId); if (!user) throw new NotFoundException('User not found'); const { password, ...result } = user; return result; } // ── internals ───────────────────────────────────────── private async assertPassword(userId: string, password: string) { const user = await this.usersService.findById(userId); if (!user) throw new NotFoundException('User not found'); const ok = await this.usersService.verifyPassword(user, password); if (!ok) throw new UnauthorizedException('Current password is incorrect'); } private async issue(opts: { userId: string; purpose: VerificationPurpose; destination: string; messageKind: OtpMessageKind; }) { const { userId, purpose, destination, messageKind } = opts; // Rate-limit: refuse if a code for this purpose was issued < cooldown ago. const recent = await this.codeRepo.findOne({ where: { userId, purpose, consumedAt: IsNull() }, order: { createdAt: 'DESC' }, }); if ( recent && Date.now() - recent.createdAt.getTime() < RESEND_COOLDOWN_MS && recent.expiresAt.getTime() > Date.now() ) { throw new HttpException( 'Please wait before requesting another code', HttpStatus.TOO_MANY_REQUESTS, ); } // Invalidate any prior unconsumed codes for this purpose. await this.codeRepo.update( { userId, purpose, consumedAt: IsNull() }, { consumedAt: new Date() }, ); const code = String(crypto.randomInt(100000, 1000000)); // 6 digits, CSPRNG const expiresAt = new Date(Date.now() + CODE_TTL_MS); const record = this.codeRepo.create({ userId, purpose, destination, codeHash: await bcrypt.hash(code, 10), expiresAt, attempts: 0, }); await this.codeRepo.save(record); await this.smsService.sendOtp(destination, code, messageKind); return { destination: this.maskPhone(destination), expiresAt }; } /** Validate and consume the latest active code for (user, purpose). */ private async consume( userId: string, purpose: VerificationPurpose, code: string, ): Promise { return this.codeRepo.manager.transaction(async (em) => { const record = await em.findOne(VerificationCode, { where: { userId, purpose, consumedAt: IsNull() }, order: { createdAt: 'DESC' }, lock: { mode: 'pessimistic_write' }, }); if (!record || record.expiresAt.getTime() < Date.now()) { throw new BadRequestException('No active code — request a new one'); } if (record.attempts >= MAX_VERIFY_ATTEMPTS) { record.consumedAt = new Date(); await em.save(record); throw new BadRequestException('Too many attempts — request a new code'); } const ok = await bcrypt.compare(code, record.codeHash); if (!ok) { record.attempts += 1; await em.save(record); throw new BadRequestException('Invalid code'); } record.consumedAt = new Date(); await em.save(record); return record; }); } /** Best-effort cleanup of long-expired codes (called opportunistically). */ async purgeExpired(): Promise { await this.codeRepo.delete({ expiresAt: LessThan(new Date(Date.now() - 24 * 60 * 60 * 1000)), }); } // +989121234567 -> +9891****4567 private maskPhone(value: string): string { return value.length > 8 ? `${value.slice(0, 5)}****${value.slice(-4)}` : value; } }