test massage
This commit is contained in:
@@ -11,3 +11,13 @@ OTP_HASH_SECRET=change_me_to_a_long_random_secret
|
|||||||
SUPER_ADMIN_MOBILE=09123456789
|
SUPER_ADMIN_MOBILE=09123456789
|
||||||
# Dev/test only: returns OTP code in API response instead of sending SMS
|
# Dev/test only: returns OTP code in API response instead of sending SMS
|
||||||
AUTH_RETURN_OTP=true
|
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
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AuthOtpEntity } from '../../entities/auth-otp.entity';
|
|||||||
import { UserEntity } from '../../entities/user.entity';
|
import { UserEntity } from '../../entities/user.entity';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { SmsService } from './sms.service';
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from './guards/roles.guard';
|
import { RolesGuard } from './guards/roles.guard';
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ import { RolesGuard } from './guards/roles.guard';
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtAuthGuard, RolesGuard],
|
providers: [AuthService, SmsService, JwtAuthGuard, RolesGuard],
|
||||||
exports: [JwtAuthGuard, RolesGuard, JwtModule],
|
exports: [JwtAuthGuard, RolesGuard, JwtModule],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
ServiceUnavailableException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
@@ -13,6 +14,7 @@ import { UserEntity, UserRole } from '../../entities/user.entity';
|
|||||||
import { JwtPayload } from './auth.types';
|
import { JwtPayload } from './auth.types';
|
||||||
import { RequestOtpDto } from './dto/request-otp.dto';
|
import { RequestOtpDto } from './dto/request-otp.dto';
|
||||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||||
|
import { SmsService } from './sms.service';
|
||||||
|
|
||||||
type RequestOtpResponse = {
|
type RequestOtpResponse = {
|
||||||
expiresInSeconds: number;
|
expiresInSeconds: number;
|
||||||
@@ -32,6 +34,7 @@ export class AuthService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly jwt: JwtService,
|
private readonly jwt: JwtService,
|
||||||
|
private readonly sms: SmsService,
|
||||||
@InjectRepository(AuthOtpEntity)
|
@InjectRepository(AuthOtpEntity)
|
||||||
private readonly otps: Repository<AuthOtpEntity>,
|
private readonly otps: Repository<AuthOtpEntity>,
|
||||||
@InjectRepository(UserEntity)
|
@InjectRepository(UserEntity)
|
||||||
@@ -59,11 +62,28 @@ export class AuthService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const returnOtpInResponse =
|
||||||
|
this.config.get<string>('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 = {
|
const response: RequestOtpResponse = {
|
||||||
expiresInSeconds: this.otpTtlSeconds,
|
expiresInSeconds: this.otpTtlSeconds,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.config.get<string>('AUTH_RETURN_OTP') === 'true') {
|
if (returnOtpInResponse) {
|
||||||
response.otpCode = code;
|
response.otpCode = code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<string>('SMS_USERNAME');
|
||||||
|
const password = this.getPassword();
|
||||||
|
const sender = this.config.get<string>('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<boolean> {
|
||||||
|
const text = `کد ورود شما: ${code}`;
|
||||||
|
return this.sendSms(mobile, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendSms(phone: string, text: string): Promise<boolean> {
|
||||||
|
const username = this.config.get<string>('SMS_USERNAME');
|
||||||
|
const password = this.getPassword();
|
||||||
|
const sender = this.config.get<string>('SMS_SENDER');
|
||||||
|
const domain = this.config.get<string>('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<string>('SMS_PASSWORD') ??
|
||||||
|
this.config.get<string>('SMS_API_KEY')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user