diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 960abad..ecdb2b4 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -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,26 +40,46 @@ 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))) { - throw new ConflictException('Email already in use'); + 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({ - 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 }; } @@ -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; } diff --git a/backend/src/common/enums.ts b/backend/src/common/enums.ts index dd0092b..4873496 100644 --- a/backend/src/common/enums.ts +++ b/backend/src/common/enums.ts @@ -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', diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index aa1f124..79e9207 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -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: { diff --git a/backend/src/notifications/sms.service.ts b/backend/src/notifications/sms.service.ts index d4b5379..87fc933 100644 --- a/backend/src/notifications/sms.service.ts +++ b/backend/src/notifications/sms.service.ts @@ -1,10 +1,20 @@ 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). - * https://kavenegar.com/rest.html#sms-lookup + * 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 = { + '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('sms.kavenegarApiKey') || undefined; - } - - private get template(): string | undefined { - return this.config.get('sms.kavenegarOtpTemplate') || undefined; + private get provider(): string { + return this.config.get('sms.provider') || 'mizbansms'; } isConfigured(): boolean { - return Boolean(this.apiKey && this.template); + if (this.provider === 'kavenegar') { + return Boolean( + this.config.get('sms.kavenegarApiKey') && + this.config.get('sms.kavenegarOtpTemplate'), + ); + } + // mizbansms + return Boolean( + this.config.get('sms.mizban.username') && + this.config.get('sms.mizban.password') && + this.config.get('sms.mizban.from'), + ); } - async sendOtp(phoneE164: string, code: string): Promise { + async sendOtp( + phoneE164: string, + code: string, + kind: OtpMessageKind = OtpMessageKind.LOGIN, + ): Promise { 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(`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 { + const apiKey = this.config.get('sms.kavenegarApiKey')!; + const template = this.config.get('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 { + const baseUrl = ( + this.config.get('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('sms.mizban.userType') ?? 2, + Username: this.config.get('sms.mizban.username'), + Password: this.config.get('sms.mizban.password'), + From: this.config.get('sms.mizban.from'), + To: [to], + Message: [message], + Api: this.config.get('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'); + } + } } diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 0b08429..eb0c3ad 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -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; } + /** + * 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 { + 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 { return this.usersRepository.findOne({ where: { email: email.toLowerCase() } }); } diff --git a/backend/src/users/verification.service.ts b/backend/src/users/verification.service.ts index 513e02f..39db321 100644 --- a/backend/src/users/verification.service.ts +++ b/backend/src/users/verification.service.ts @@ -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, @@ -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 }; }