diff --git a/.env.example b/.env.example index 07f3d1f..665faf8 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,13 @@ OTP_HASH_SECRET=change_me_to_a_long_random_secret SUPER_ADMIN_MOBILE=09123456789 # Dev/test only: returns OTP code in API response instead of sending SMS AUTH_RETURN_OTP=true + +# 0098sms.com — used when AUTH_RETURN_OTP is not true +# From panel: account username, password (or web service password), approved sender line (FROM) +SMS_USERNAME= +SMS_PASSWORD= +# Alternative name supported for password (same as some older projects) +# SMS_API_KEY= +SMS_SENDER= +# Optional; default is 0098 +# SMS_DOMAIN=0098 diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 2c6235c..8f1a28f 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -6,6 +6,7 @@ import { AuthOtpEntity } from '../../entities/auth-otp.entity'; import { UserEntity } from '../../entities/user.entity'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; +import { SmsService } from './sms.service'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { RolesGuard } from './guards/roles.guard'; @@ -25,7 +26,7 @@ import { RolesGuard } from './guards/roles.guard'; }), ], controllers: [AuthController], - providers: [AuthService, JwtAuthGuard, RolesGuard], + providers: [AuthService, SmsService, JwtAuthGuard, RolesGuard], exports: [JwtAuthGuard, RolesGuard, JwtModule], }) export class AuthModule {} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index ef5b7b0..b8c2c46 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, + ServiceUnavailableException, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -13,6 +14,7 @@ import { UserEntity, UserRole } from '../../entities/user.entity'; import { JwtPayload } from './auth.types'; import { RequestOtpDto } from './dto/request-otp.dto'; import { VerifyOtpDto } from './dto/verify-otp.dto'; +import { SmsService } from './sms.service'; type RequestOtpResponse = { expiresInSeconds: number; @@ -32,6 +34,7 @@ export class AuthService { constructor( private readonly config: ConfigService, private readonly jwt: JwtService, + private readonly sms: SmsService, @InjectRepository(AuthOtpEntity) private readonly otps: Repository, @InjectRepository(UserEntity) @@ -59,11 +62,28 @@ export class AuthService { }), ); + const returnOtpInResponse = + this.config.get('AUTH_RETURN_OTP') === 'true'; + + if (!returnOtpInResponse) { + if (!this.sms.isConfigured()) { + throw new BadRequestException( + 'SMS is not configured. Set SMS_USERNAME, SMS_PASSWORD (or SMS_API_KEY), and SMS_SENDER.', + ); + } + const sent = await this.sms.sendOtpSms(mobile, code); + if (!sent) { + throw new ServiceUnavailableException( + 'Could not send SMS. Please try again later.', + ); + } + } + const response: RequestOtpResponse = { expiresInSeconds: this.otpTtlSeconds, }; - if (this.config.get('AUTH_RETURN_OTP') === 'true') { + if (returnOtpInResponse) { response.otpCode = code; } diff --git a/src/modules/auth/sms.service.ts b/src/modules/auth/sms.service.ts new file mode 100644 index 0000000..9811090 --- /dev/null +++ b/src/modules/auth/sms.service.ts @@ -0,0 +1,90 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +/** HTTP endpoint documented by 0098sms.com for sending via query string. */ +const SMS_0098_SEND_URL = + 'http://www.0098sms.com/sendsmslink.aspx'; + +@Injectable() +export class SmsService { + private readonly logger = new Logger(SmsService.name); + + constructor(private readonly config: ConfigService) {} + + /** True when username, password, and sender line are all set. */ + isConfigured(): boolean { + const username = this.config.get('SMS_USERNAME'); + const password = this.getPassword(); + const sender = this.config.get('SMS_SENDER'); + return Boolean(username && password && sender); + } + + /** + * Formats mobile for 0098 `TO` (Iranian mobiles as 09xxxxxxxxx). + */ + normalizePhoneForSms(mobile: string): string { + const digits = String(mobile ?? '').replace(/\D/g, ''); + if (digits.startsWith('98') && digits.length === 12) { + return `0${digits.slice(2)}`; + } + if (digits.startsWith('0') && digits.length === 11) { + return digits; + } + if (digits.length === 10 && digits.startsWith('9')) { + return `0${digits}`; + } + return digits; + } + + async sendOtpSms(mobile: string, code: string): Promise { + const text = `کد ورود شما: ${code}`; + return this.sendSms(mobile, text); + } + + async sendSms(phone: string, text: string): Promise { + const username = this.config.get('SMS_USERNAME'); + const password = this.getPassword(); + const sender = this.config.get('SMS_SENDER'); + const domain = this.config.get('SMS_DOMAIN') ?? '0098'; + + if (!username || !password || !sender) { + return false; + } + + const params = new URLSearchParams({ + FROM: sender, + TO: this.normalizePhoneForSms(phone), + TEXT: text, + USERNAME: username, + PASSWORD: password, + DOMAIN: domain, + }); + + const url = `${SMS_0098_SEND_URL}?${params.toString()}`; + + try { + const res = await fetch(url); + const body = await res.text(); + if (!res.ok) { + this.logger.warn( + `SMS HTTP ${res.status}: ${body.slice(0, 300)}`, + ); + return false; + } + return true; + } catch (e) { + this.logger.error( + '[SMS] request failed', + e instanceof Error ? e.stack : e, + ); + return false; + } + } + + private getPassword(): string | undefined { + return ( + this.config.get('SMS_PASSWORD') ?? + this.config.get('SMS_API_KEY') + ); + } +}