feat(auth): add MizbanSMS OTP provider with per-flow messages and signup recovery

- Add MizbanSMS as selectable SMS provider (SMS_PROVIDER), keep Kavenegar
- Distinct OTP wording per flow via OtpMessageKind (register/login/change-phone)
- register() resumes an unverified account instead of blocking re-registration
- Hourly cleanup of abandoned unverified accounts (>24h) + expired OTP codes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-17 17:44:58 +03:30
parent 7d7971744e
commit fc3d0a7e04
6 changed files with 265 additions and 33 deletions
+47 -6
View File
@@ -6,6 +6,9 @@ import {
NotFoundException,
HttpException,
HttpStatus,
Logger,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, LessThan, Repository } from 'typeorm';
@@ -14,13 +17,18 @@ 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 { VerificationPurpose } from '../common/enums';
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.
*
@@ -32,7 +40,10 @@ const MAX_VERIFY_ATTEMPTS = 5;
* additionally re-authenticates with the current password).
*/
@Injectable()
export class VerificationService {
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>,
@@ -40,10 +51,37 @@ export class VerificationService {
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. */
async issueLoginOtp(user: User) {
/**
* 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');
}
@@ -51,6 +89,7 @@ export class VerificationService {
userId: user.id,
purpose: VerificationPurpose.LOGIN,
destination: user.phone,
messageKind: kind,
});
}
@@ -84,6 +123,7 @@ export class VerificationService {
userId,
purpose: VerificationPurpose.CHANGE_PHONE,
destination: phone,
messageKind: OtpMessageKind.CHANGE_PHONE,
});
}
@@ -119,8 +159,9 @@ export class VerificationService {
userId: string;
purpose: VerificationPurpose;
destination: string;
messageKind: OtpMessageKind;
}) {
const { userId, purpose, destination } = opts;
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({
@@ -156,7 +197,7 @@ export class VerificationService {
});
await this.codeRepo.save(record);
await this.smsService.sendOtp(destination, code);
await this.smsService.sendOtp(destination, code, messageKind);
return { destination: this.maskPhone(destination), expiresAt };
}