Merge branch 'feat/sms-otp-mizbansms'
feat(auth): MizbanSMS OTP provider, per-flow messages, signup recovery + cleanup Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import { RegisterDto } from './dto/register.dto';
|
|||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
import { normalizeIranMobile } from '../common/phone.util';
|
import { normalizeIranMobile } from '../common/phone.util';
|
||||||
|
import { OtpMessageKind } from '../common/enums';
|
||||||
|
|
||||||
/** Returned when an action needs phone verification before tokens are issued. */
|
/** Returned when an action needs phone verification before tokens are issued. */
|
||||||
export interface VerificationRequired {
|
export interface VerificationRequired {
|
||||||
@@ -39,17 +40,34 @@ export class AuthService {
|
|||||||
if (!phone) {
|
if (!phone) {
|
||||||
throw new BadRequestException('Invalid mobile number');
|
throw new BadRequestException('Invalid mobile number');
|
||||||
}
|
}
|
||||||
if (await this.usersService.findByPhone(phone)) {
|
|
||||||
|
// An unverified account means a previous signup was never confirmed — let
|
||||||
|
// the user resume it. Only a *verified* phone is a real duplicate.
|
||||||
|
const existing = await this.usersService.findByPhone(phone);
|
||||||
|
if (existing?.phoneVerified) {
|
||||||
throw new ConflictException('Mobile number already registered');
|
throw new ConflictException('Mobile number already registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = registerDto.email?.trim().toLowerCase() || null;
|
const email = registerDto.email?.trim().toLowerCase() || null;
|
||||||
if (email && (await this.usersService.findByEmail(email))) {
|
if (email) {
|
||||||
|
const emailOwner = await this.usersService.findByEmail(email);
|
||||||
|
if (emailOwner && emailOwner.id !== existing?.id) {
|
||||||
throw new ConflictException('Email already in use');
|
throw new ConflictException('Email already in use');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(registerDto.password, 12);
|
const hashedPassword = await bcrypt.hash(registerDto.password, 12);
|
||||||
const user = await this.usersService.create({
|
|
||||||
|
// Resume the stale record (overwriting name/password/email) instead of
|
||||||
|
// locking the number behind it, or create a fresh account.
|
||||||
|
const user = existing
|
||||||
|
? await this.usersService.update(existing.id, {
|
||||||
|
email,
|
||||||
|
firstName: registerDto.firstName,
|
||||||
|
lastName: registerDto.lastName,
|
||||||
|
password: hashedPassword,
|
||||||
|
})
|
||||||
|
: await this.usersService.create({
|
||||||
phone,
|
phone,
|
||||||
email,
|
email,
|
||||||
firstName: registerDto.firstName,
|
firstName: registerDto.firstName,
|
||||||
@@ -58,7 +76,10 @@ export class AuthService {
|
|||||||
phoneVerified: false,
|
phoneVerified: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { destination } = await this.verificationService.issueLoginOtp(user);
|
const { destination } = await this.verificationService.issueLoginOtp(
|
||||||
|
user,
|
||||||
|
OtpMessageKind.REGISTER,
|
||||||
|
);
|
||||||
return { requiresVerification: true, phone: destination };
|
return { requiresVerification: true, phone: destination };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +108,12 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user.phoneVerified) {
|
if (!user.phoneVerified) {
|
||||||
const { destination } = await this.verificationService.issueLoginOtp(user);
|
// Account exists but never finished signup verification — treat as
|
||||||
|
// registration completion (welcome wording), not a passwordless login.
|
||||||
|
const { destination } = await this.verificationService.issueLoginOtp(
|
||||||
|
user,
|
||||||
|
OtpMessageKind.REGISTER,
|
||||||
|
);
|
||||||
return { requiresVerification: true, phone: destination } as VerificationRequired;
|
return { requiresVerification: true, phone: destination } as VerificationRequired;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ export enum VerificationPurpose {
|
|||||||
CHANGE_PHONE = 'change_phone',
|
CHANGE_PHONE = 'change_phone',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which OTP wording to send. Registration and passwordless login share the
|
||||||
|
* LOGIN verification purpose but want different message text, so the message
|
||||||
|
* kind is tracked separately from the purpose.
|
||||||
|
*/
|
||||||
|
export enum OtpMessageKind {
|
||||||
|
/** First-time signup / completing verification of an unverified account. */
|
||||||
|
REGISTER = 'register',
|
||||||
|
/** Passwordless one-time-password login for a verified account. */
|
||||||
|
LOGIN = 'login',
|
||||||
|
/** Confirming a new phone number from the account page. */
|
||||||
|
CHANGE_PHONE = 'change_phone',
|
||||||
|
}
|
||||||
|
|
||||||
export enum TicketDepartment {
|
export enum TicketDepartment {
|
||||||
TECHNICAL = 'technical',
|
TECHNICAL = 'technical',
|
||||||
SALES = 'sales',
|
SALES = 'sales',
|
||||||
|
|||||||
@@ -84,10 +84,37 @@ export default () => ({
|
|||||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||||
},
|
},
|
||||||
|
|
||||||
// OTP SMS via Kavenegar (verify/lookup template API).
|
// OTP SMS. Provider selectable via SMS_PROVIDER ('mizbansms' | 'kavenegar').
|
||||||
sms: {
|
sms: {
|
||||||
|
provider: (process.env.SMS_PROVIDER || 'mizbansms').trim().toLowerCase(),
|
||||||
|
|
||||||
|
// Kavenegar (verify/lookup template API) — kept for fallback.
|
||||||
kavenegarApiKey: process.env.KAVENEGAR_API_KEY || '',
|
kavenegarApiKey: process.env.KAVENEGAR_API_KEY || '',
|
||||||
kavenegarOtpTemplate: process.env.KAVENEGAR_OTP_TEMPLATE || '',
|
kavenegarOtpTemplate: process.env.KAVENEGAR_OTP_TEMPLATE || '',
|
||||||
|
|
||||||
|
// MizbanSMS (services.mizbansms.com) — sends full message text over a
|
||||||
|
// dedicated line, so the OTP wording is composed here, not from a template.
|
||||||
|
mizban: {
|
||||||
|
baseUrl: process.env.MIZBANSMS_BASE_URL || 'http://services.mizbansms.com',
|
||||||
|
username: process.env.MIZBANSMS_USERNAME || '',
|
||||||
|
password: process.env.MIZBANSMS_PASSWORD || '',
|
||||||
|
from: process.env.MIZBANSMS_FROM || '5000467254',
|
||||||
|
api: parseInt(process.env.MIZBANSMS_API || '2016', 10),
|
||||||
|
userType: parseInt(process.env.MIZBANSMS_USERTYPE || '2', 10),
|
||||||
|
// Per-message-kind wording. `{code}` (or `{{CODE}}`) is replaced with the
|
||||||
|
// 6-digit OTP. \n is allowed for multi-line messages.
|
||||||
|
templates: {
|
||||||
|
register:
|
||||||
|
process.env.MIZBANSMS_OTP_TEMPLATE_REGISTER ||
|
||||||
|
'به ابربان خوش آمدید.\nکد تأیید شما: {code}',
|
||||||
|
login:
|
||||||
|
process.env.MIZBANSMS_OTP_TEMPLATE_LOGIN ||
|
||||||
|
'رمز یکبار مصرف ابربان:\n{code}',
|
||||||
|
changePhone:
|
||||||
|
process.env.MIZBANSMS_OTP_TEMPLATE_CHANGE_PHONE ||
|
||||||
|
'کد تأیید شماره جدید ابربان:\n{code}',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
registry: {
|
registry: {
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { toLocalMobile } from '../common/phone.util';
|
import { toLocalMobile } from '../common/phone.util';
|
||||||
|
import { OtpMessageKind } from '../common/enums';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SMS delivery via Kavenegar's Verify Lookup API (OTP templates).
|
* OTP SMS delivery. Two providers are supported, selected by `sms.provider`:
|
||||||
|
*
|
||||||
|
* - `kavenegar` — Verify Lookup API. We send only the token + a pre-approved
|
||||||
|
* template name; Kavenegar composes the final message text.
|
||||||
* https://kavenegar.com/rest.html#sms-lookup
|
* https://kavenegar.com/rest.html#sms-lookup
|
||||||
*
|
*
|
||||||
|
* - `mizbansms` — services.mizbansms.com. There is NO server-side OTP template:
|
||||||
|
* we send the full message text ourselves over a dedicated line, so the OTP
|
||||||
|
* wording comes from `sms.mizban.otpTemplate` ({code} placeholder).
|
||||||
|
* Success returns a long numeric message id; the documented failures are the
|
||||||
|
* integer codes 1001–1011.
|
||||||
|
*
|
||||||
* When credentials are absent we fall back to logging the code in development
|
* When credentials are absent we fall back to logging the code in development
|
||||||
* so the flow stays testable; in production a missing config is a hard error.
|
* so the flow stays testable; in production a missing config is a hard error.
|
||||||
*/
|
*/
|
||||||
@@ -13,21 +23,47 @@ import { toLocalMobile } from '../common/phone.util';
|
|||||||
export class SmsService {
|
export class SmsService {
|
||||||
private readonly logger = new Logger(SmsService.name);
|
private readonly logger = new Logger(SmsService.name);
|
||||||
|
|
||||||
|
// MizbanSMS documented error codes -> human-readable reason.
|
||||||
|
private static readonly MIZBAN_ERRORS: Record<string, string> = {
|
||||||
|
'1001': 'شماره موبایل خالی است',
|
||||||
|
'1002': 'بیش از ۹۰ شماره وارد شده',
|
||||||
|
'1003': 'شارژ کم است',
|
||||||
|
'1004': 'مقادیر پارامترها اشتباه است',
|
||||||
|
'1005': 'مسیر مورد نظر موجود نیست',
|
||||||
|
'1006': 'کاربر غیرفعال است',
|
||||||
|
'1007': 'اعتبار زمانی به اتمام رسیده است',
|
||||||
|
'1008': 'ارسال مسیر فعال نیست',
|
||||||
|
'1009': 'کاربر موجود نیست',
|
||||||
|
'1010': 'نام کاربری یا کلمه عبور خالی است',
|
||||||
|
'1011': 'پیام خالی است',
|
||||||
|
};
|
||||||
|
|
||||||
constructor(private readonly config: ConfigService) {}
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
private get apiKey(): string | undefined {
|
private get provider(): string {
|
||||||
return this.config.get<string>('sms.kavenegarApiKey') || undefined;
|
return this.config.get<string>('sms.provider') || 'mizbansms';
|
||||||
}
|
|
||||||
|
|
||||||
private get template(): string | undefined {
|
|
||||||
return this.config.get<string>('sms.kavenegarOtpTemplate') || undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isConfigured(): boolean {
|
isConfigured(): boolean {
|
||||||
return Boolean(this.apiKey && this.template);
|
if (this.provider === 'kavenegar') {
|
||||||
|
return Boolean(
|
||||||
|
this.config.get<string>('sms.kavenegarApiKey') &&
|
||||||
|
this.config.get<string>('sms.kavenegarOtpTemplate'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// mizbansms
|
||||||
|
return Boolean(
|
||||||
|
this.config.get<string>('sms.mizban.username') &&
|
||||||
|
this.config.get<string>('sms.mizban.password') &&
|
||||||
|
this.config.get<string>('sms.mizban.from'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendOtp(phoneE164: string, code: string): Promise<void> {
|
async sendOtp(
|
||||||
|
phoneE164: string,
|
||||||
|
code: string,
|
||||||
|
kind: OtpMessageKind = OtpMessageKind.LOGIN,
|
||||||
|
): Promise<void> {
|
||||||
if (!this.isConfigured()) {
|
if (!this.isConfigured()) {
|
||||||
if (this.config.get('nodeEnv') !== 'production') {
|
if (this.config.get('nodeEnv') !== 'production') {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -38,12 +74,38 @@ export class SmsService {
|
|||||||
throw new ServiceUnavailableException('SMS delivery is not configured');
|
throw new ServiceUnavailableException('SMS delivery is not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.provider === 'kavenegar') {
|
||||||
|
return this.sendViaKavenegar(phoneE164, code);
|
||||||
|
}
|
||||||
|
return this.sendViaMizban(phoneE164, code, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the MizbanSMS message text for a kind, filling in the code. */
|
||||||
|
private mizbanMessage(kind: OtpMessageKind, code: string): string {
|
||||||
|
const key =
|
||||||
|
kind === OtpMessageKind.REGISTER
|
||||||
|
? 'register'
|
||||||
|
: kind === OtpMessageKind.CHANGE_PHONE
|
||||||
|
? 'changePhone'
|
||||||
|
: 'login';
|
||||||
|
const template =
|
||||||
|
this.config.get<string>(`sms.mizban.templates.${key}`) || '{code}';
|
||||||
|
// Accept {code}, {{code}}, {{CODE}}, { code } … as the placeholder.
|
||||||
|
return template.replace(/\{\{?\s*code\s*\}?\}/gi, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kavenegar ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private async sendViaKavenegar(phoneE164: string, code: string): Promise<void> {
|
||||||
|
const apiKey = this.config.get<string>('sms.kavenegarApiKey')!;
|
||||||
|
const template = this.config.get<string>('sms.kavenegarOtpTemplate')!;
|
||||||
const receptor = toLocalMobile(phoneE164);
|
const receptor = toLocalMobile(phoneE164);
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
`https://api.kavenegar.com/v1/${this.apiKey}/verify/lookup.json` +
|
`https://api.kavenegar.com/v1/${apiKey}/verify/lookup.json` +
|
||||||
`?receptor=${encodeURIComponent(receptor)}` +
|
`?receptor=${encodeURIComponent(receptor)}` +
|
||||||
`&token=${encodeURIComponent(code)}` +
|
`&token=${encodeURIComponent(code)}` +
|
||||||
`&template=${encodeURIComponent(this.template!)}`;
|
`&template=${encodeURIComponent(template)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { method: 'GET' });
|
const res = await fetch(url, { method: 'GET' });
|
||||||
@@ -61,4 +123,51 @@ export class SmsService {
|
|||||||
throw new ServiceUnavailableException('Failed to send SMS code');
|
throw new ServiceUnavailableException('Failed to send SMS code');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── MizbanSMS ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private async sendViaMizban(
|
||||||
|
phoneE164: string,
|
||||||
|
code: string,
|
||||||
|
kind: OtpMessageKind,
|
||||||
|
): Promise<void> {
|
||||||
|
const baseUrl = (
|
||||||
|
this.config.get<string>('sms.mizban.baseUrl') ||
|
||||||
|
'http://services.mizbansms.com'
|
||||||
|
).replace(/\/+$/, '');
|
||||||
|
const message = this.mizbanMessage(kind, code);
|
||||||
|
const to = toLocalMobile(phoneE164); // 09XXXXXXXXX
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
Usertype: this.config.get<number>('sms.mizban.userType') ?? 2,
|
||||||
|
Username: this.config.get<string>('sms.mizban.username'),
|
||||||
|
Password: this.config.get<string>('sms.mizban.password'),
|
||||||
|
From: this.config.get<string>('sms.mizban.from'),
|
||||||
|
To: [to],
|
||||||
|
Message: [message],
|
||||||
|
Api: this.config.get<number>('sms.mizban.api'),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/Customer/SendSMS`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const raw = (await res.text()).trim();
|
||||||
|
|
||||||
|
// Success returns a long numeric message id; failures are codes 1001–1011.
|
||||||
|
const reason = SmsService.MIZBAN_ERRORS[raw.replace(/^"|"$/g, '')];
|
||||||
|
if (!res.ok || reason) {
|
||||||
|
this.logger.error(
|
||||||
|
`MizbanSMS OTP send failed (${res.status}): ${reason ?? raw}`,
|
||||||
|
);
|
||||||
|
throw new ServiceUnavailableException('Failed to send SMS code');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServiceUnavailableException) throw err;
|
||||||
|
this.logger.error(`MizbanSMS request error: ${(err as Error).message}`);
|
||||||
|
throw new ServiceUnavailableException('Failed to send SMS code');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, ILike } from 'typeorm';
|
import { Repository, ILike, LessThan } from 'typeorm';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { User } from './entities/user.entity';
|
import { User } from './entities/user.entity';
|
||||||
import { UserRole } from '../common/enums';
|
import { UserRole } from '../common/enums';
|
||||||
@@ -68,6 +68,21 @@ export class UsersService {
|
|||||||
return result as Omit<User, 'password'>;
|
return result as Omit<User, 'password'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove accounts whose phone was never verified and that were created longer
|
||||||
|
* than `olderThanMs` ago — abandoned signups that would otherwise lock the
|
||||||
|
* number forever. Admin-created accounts are pre-verified, so never matched.
|
||||||
|
* VerificationCode rows cascade-delete with the user. Returns the count removed.
|
||||||
|
*/
|
||||||
|
async deleteStaleUnverified(olderThanMs: number): Promise<number> {
|
||||||
|
const cutoff = new Date(Date.now() - olderThanMs);
|
||||||
|
const result = await this.usersRepository.delete({
|
||||||
|
phoneVerified: false,
|
||||||
|
createdAt: LessThan(cutoff),
|
||||||
|
});
|
||||||
|
return result.affected ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
async findByEmail(email: string): Promise<User | null> {
|
async findByEmail(email: string): Promise<User | null> {
|
||||||
return this.usersRepository.findOne({ where: { email: email.toLowerCase() } });
|
return this.usersRepository.findOne({ where: { email: email.toLowerCase() } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { IsNull, LessThan, Repository } from '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 { User } from './entities/user.entity';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
import { SmsService } from '../notifications/sms.service';
|
import { SmsService } from '../notifications/sms.service';
|
||||||
import { VerificationPurpose } from '../common/enums';
|
import { OtpMessageKind, VerificationPurpose } from '../common/enums';
|
||||||
import { normalizeIranMobile } from '../common/phone.util';
|
import { normalizeIranMobile } from '../common/phone.util';
|
||||||
|
|
||||||
const CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
const CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
const RESEND_COOLDOWN_MS = 60 * 1000; // 60 seconds between requests
|
const RESEND_COOLDOWN_MS = 60 * 1000; // 60 seconds between requests
|
||||||
const MAX_VERIFY_ATTEMPTS = 5;
|
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.
|
* One-time SMS code flows for phone ownership.
|
||||||
*
|
*
|
||||||
@@ -32,7 +40,10 @@ const MAX_VERIFY_ATTEMPTS = 5;
|
|||||||
* additionally re-authenticates with the current password).
|
* additionally re-authenticates with the current password).
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VerificationService {
|
export class VerificationService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(VerificationService.name);
|
||||||
|
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(VerificationCode)
|
@InjectRepository(VerificationCode)
|
||||||
private readonly codeRepo: Repository<VerificationCode>,
|
private readonly codeRepo: Repository<VerificationCode>,
|
||||||
@@ -40,10 +51,37 @@ export class VerificationService {
|
|||||||
private readonly smsService: SmsService,
|
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 ───────────
|
// ── 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) {
|
if (!user.phone) {
|
||||||
throw new BadRequestException('Account has no mobile number');
|
throw new BadRequestException('Account has no mobile number');
|
||||||
}
|
}
|
||||||
@@ -51,6 +89,7 @@ export class VerificationService {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
purpose: VerificationPurpose.LOGIN,
|
purpose: VerificationPurpose.LOGIN,
|
||||||
destination: user.phone,
|
destination: user.phone,
|
||||||
|
messageKind: kind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +123,7 @@ export class VerificationService {
|
|||||||
userId,
|
userId,
|
||||||
purpose: VerificationPurpose.CHANGE_PHONE,
|
purpose: VerificationPurpose.CHANGE_PHONE,
|
||||||
destination: phone,
|
destination: phone,
|
||||||
|
messageKind: OtpMessageKind.CHANGE_PHONE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,8 +159,9 @@ export class VerificationService {
|
|||||||
userId: string;
|
userId: string;
|
||||||
purpose: VerificationPurpose;
|
purpose: VerificationPurpose;
|
||||||
destination: string;
|
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.
|
// Rate-limit: refuse if a code for this purpose was issued < cooldown ago.
|
||||||
const recent = await this.codeRepo.findOne({
|
const recent = await this.codeRepo.findOne({
|
||||||
@@ -156,7 +197,7 @@ export class VerificationService {
|
|||||||
});
|
});
|
||||||
await this.codeRepo.save(record);
|
await this.codeRepo.save(record);
|
||||||
|
|
||||||
await this.smsService.sendOtp(destination, code);
|
await this.smsService.sendOtp(destination, code, messageKind);
|
||||||
|
|
||||||
return { destination: this.maskPhone(destination), expiresAt };
|
return { destination: this.maskPhone(destination), expiresAt };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user