8163665c86
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>
255 lines
8.4 KiB
TypeScript
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;
|
|
}
|
|
}
|