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:
@@ -13,6 +13,7 @@ import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { normalizeIranMobile } from '../common/phone.util';
|
||||
import { OtpMessageKind } from '../common/enums';
|
||||
|
||||
/** Returned when an action needs phone verification before tokens are issued. */
|
||||
export interface VerificationRequired {
|
||||
@@ -39,17 +40,34 @@ export class AuthService {
|
||||
if (!phone) {
|
||||
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');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
email,
|
||||
firstName: registerDto.firstName,
|
||||
@@ -58,7 +76,10 @@ export class AuthService {
|
||||
phoneVerified: false,
|
||||
});
|
||||
|
||||
const { destination } = await this.verificationService.issueLoginOtp(user);
|
||||
const { destination } = await this.verificationService.issueLoginOtp(
|
||||
user,
|
||||
OtpMessageKind.REGISTER,
|
||||
);
|
||||
return { requiresVerification: true, phone: destination };
|
||||
}
|
||||
|
||||
@@ -87,7 +108,12 @@ export class AuthService {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,20 @@ export enum VerificationPurpose {
|
||||
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 {
|
||||
TECHNICAL = 'technical',
|
||||
SALES = 'sales',
|
||||
|
||||
@@ -84,10 +84,37 @@ export default () => ({
|
||||
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: {
|
||||
provider: (process.env.SMS_PROVIDER || 'mizbansms').trim().toLowerCase(),
|
||||
|
||||
// Kavenegar (verify/lookup template API) — kept for fallback.
|
||||
kavenegarApiKey: process.env.KAVENEGAR_API_KEY || '',
|
||||
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: {
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
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
|
||||
*
|
||||
* - `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
|
||||
* 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 {
|
||||
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) {}
|
||||
|
||||
private get apiKey(): string | undefined {
|
||||
return this.config.get<string>('sms.kavenegarApiKey') || undefined;
|
||||
}
|
||||
|
||||
private get template(): string | undefined {
|
||||
return this.config.get<string>('sms.kavenegarOtpTemplate') || undefined;
|
||||
private get provider(): string {
|
||||
return this.config.get<string>('sms.provider') || 'mizbansms';
|
||||
}
|
||||
|
||||
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.config.get('nodeEnv') !== 'production') {
|
||||
this.logger.warn(
|
||||
@@ -38,12 +74,38 @@ export class SmsService {
|
||||
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 url =
|
||||
`https://api.kavenegar.com/v1/${this.apiKey}/verify/lookup.json` +
|
||||
`https://api.kavenegar.com/v1/${apiKey}/verify/lookup.json` +
|
||||
`?receptor=${encodeURIComponent(receptor)}` +
|
||||
`&token=${encodeURIComponent(code)}` +
|
||||
`&template=${encodeURIComponent(this.template!)}`;
|
||||
`&template=${encodeURIComponent(template)}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { method: 'GET' });
|
||||
@@ -61,4 +123,51 @@ export class SmsService {
|
||||
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,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, ILike } from 'typeorm';
|
||||
import { Repository, ILike, LessThan } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { User } from './entities/user.entity';
|
||||
import { UserRole } from '../common/enums';
|
||||
@@ -68,6 +68,21 @@ export class UsersService {
|
||||
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> {
|
||||
return this.usersRepository.findOne({ where: { email: email.toLowerCase() } });
|
||||
}
|
||||
|
||||
@@ -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