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
+39 -13
View File
@@ -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,26 +40,46 @@ 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) {
throw new ConflictException('Email already in use'); const emailOwner = await this.usersService.findByEmail(email);
if (emailOwner && emailOwner.id !== existing?.id) {
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({
phone,
email,
firstName: registerDto.firstName,
lastName: registerDto.lastName,
password: hashedPassword,
phoneVerified: false,
});
const { destination } = await this.verificationService.issueLoginOtp(user); // 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,
email,
firstName: registerDto.firstName,
lastName: registerDto.lastName,
password: hashedPassword,
phoneVerified: false,
});
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;
} }
+14
View File
@@ -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',
+28 -1
View File
@@ -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: {
+121 -12
View File
@@ -1,10 +1,20 @@
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`:
* https://kavenegar.com/rest.html#sms-lookup *
* - `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
*
* - `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 10011011.
* *
* 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 10011011.
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');
}
}
} }
+16 -1
View File
@@ -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() } });
} }
+47 -6
View File
@@ -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 };
} }