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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user