Files
cloud-host/backend/src/users/verification.service.ts
T
keyhan 8163665c86 fix(platform): close remaining audit findings from security review
Harden preview/deploy flows, OTP generation, zip extraction, and multi-replica billing races; document full remediation status in AUDIT-STATUS.fa.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 12:30:22 +03:30

255 lines
8.4 KiB
TypeScript

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<VerificationCode>,
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<VerificationCode> {
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<void> {
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;
}
}