update package-lock.json 050224
This commit is contained in:
@@ -9,9 +9,16 @@ DB_SYNC=true
|
|||||||
JWT_SECRET=change_me_to_a_long_random_secret
|
JWT_SECRET=change_me_to_a_long_random_secret
|
||||||
OTP_HASH_SECRET=change_me_to_a_long_random_secret
|
OTP_HASH_SECRET=change_me_to_a_long_random_secret
|
||||||
SUPER_ADMIN_MOBILE=09123456789
|
SUPER_ADMIN_MOBILE=09123456789
|
||||||
|
# Min seconds between OTP requests for the same mobile
|
||||||
|
OTP_REQUEST_COOLDOWN_SECONDS=60
|
||||||
|
# Max OTP requests per mobile per rolling hour
|
||||||
|
OTP_REQUEST_MAX_PER_HOUR=5
|
||||||
# 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
|
||||||
|
|
||||||
|
# Comma-separated frontend origins (required in production for browser clients)
|
||||||
|
# CORS_ORIGINS=https://app.example.com,http://localhost:5173
|
||||||
|
|
||||||
# 0098sms.com — used when AUTH_RETURN_OTP is not true
|
# 0098sms.com — used when AUTH_RETURN_OTP is not true
|
||||||
# From panel: account username, password (or web service password), approved sender line (FROM)
|
# From panel: account username, password (or web service password), approved sender line (FROM)
|
||||||
SMS_USERNAME=
|
SMS_USERNAME=
|
||||||
|
|||||||
Generated
+3530
-1432
File diff suppressed because it is too large
Load Diff
+13
@@ -4,8 +4,21 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
function configureCors(app: Awaited<ReturnType<typeof NestFactory.create>>) {
|
||||||
|
const raw = process.env.CORS_ORIGINS?.trim();
|
||||||
|
if (raw) {
|
||||||
|
const origins = raw.split(',').map((o) => o.trim()).filter(Boolean);
|
||||||
|
app.enableCors({ origin: origins, credentials: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
app.enableCors({ origin: true, credentials: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
configureCors(app);
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
forbidNonWhitelisted: true,
|
forbidNonWhitelisted: true,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -28,8 +31,11 @@ type VerifyOtpResponse = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
private readonly logger = new Logger(AuthService.name);
|
||||||
private readonly otpTtlSeconds: number;
|
private readonly otpTtlSeconds: number;
|
||||||
private readonly maxAttempts: number;
|
private readonly maxAttempts: number;
|
||||||
|
private readonly otpRequestCooldownSeconds: number;
|
||||||
|
private readonly otpRequestMaxPerHour: number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
@@ -42,10 +48,20 @@ export class AuthService {
|
|||||||
) {
|
) {
|
||||||
this.otpTtlSeconds = this.readPositiveInt('OTP_TTL_SECONDS', 120);
|
this.otpTtlSeconds = this.readPositiveInt('OTP_TTL_SECONDS', 120);
|
||||||
this.maxAttempts = this.readPositiveInt('OTP_MAX_ATTEMPTS', 5);
|
this.maxAttempts = this.readPositiveInt('OTP_MAX_ATTEMPTS', 5);
|
||||||
|
this.otpRequestCooldownSeconds = this.readPositiveInt(
|
||||||
|
'OTP_REQUEST_COOLDOWN_SECONDS',
|
||||||
|
60,
|
||||||
|
);
|
||||||
|
this.otpRequestMaxPerHour = this.readPositiveInt(
|
||||||
|
'OTP_REQUEST_MAX_PER_HOUR',
|
||||||
|
5,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestOtp(dto: RequestOtpDto): Promise<RequestOtpResponse> {
|
async requestOtp(dto: RequestOtpDto): Promise<RequestOtpResponse> {
|
||||||
const mobile = this.normalizeMobile(dto.mobile);
|
const mobile = this.normalizeMobile(dto.mobile);
|
||||||
|
await this.assertOtpRequestAllowed(mobile);
|
||||||
|
|
||||||
const code = this.generateOtpCode();
|
const code = this.generateOtpCode();
|
||||||
const expiresAt = new Date(Date.now() + this.otpTtlSeconds * 1000);
|
const expiresAt = new Date(Date.now() + this.otpTtlSeconds * 1000);
|
||||||
|
|
||||||
@@ -73,6 +89,9 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
const sent = await this.sms.sendOtpSms(mobile, code);
|
const sent = await this.sms.sendOtpSms(mobile, code);
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
|
this.logger.warn(
|
||||||
|
`OTP SMS failed for ${this.maskMobile(mobile)}`,
|
||||||
|
);
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
'Could not send SMS. Please try again later.',
|
'Could not send SMS. Please try again later.',
|
||||||
);
|
);
|
||||||
@@ -208,6 +227,46 @@ export class AuthService {
|
|||||||
return configured ? this.normalizeMobile(configured) === mobile : false;
|
return configured ? this.normalizeMobile(configured) === mobile : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertOtpRequestAllowed(mobile: string): Promise<void> {
|
||||||
|
const latest = await this.otps.findOne({
|
||||||
|
where: { mobile },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (latest) {
|
||||||
|
const elapsedMs = Date.now() - latest.createdAt.getTime();
|
||||||
|
const cooldownMs = this.otpRequestCooldownSeconds * 1000;
|
||||||
|
if (elapsedMs < cooldownMs) {
|
||||||
|
const retryAfterSeconds = Math.ceil(
|
||||||
|
(cooldownMs - elapsedMs) / 1000,
|
||||||
|
);
|
||||||
|
throw new HttpException(
|
||||||
|
`Please wait ${retryAfterSeconds} seconds before requesting a new code`,
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||||
|
const recentCount = await this.otps.count({
|
||||||
|
where: { mobile, createdAt: MoreThan(hourAgo) },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recentCount >= this.otpRequestMaxPerHour) {
|
||||||
|
throw new HttpException(
|
||||||
|
'Too many OTP requests. Please try again later',
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private maskMobile(mobile: string): string {
|
||||||
|
if (mobile.length <= 6) {
|
||||||
|
return '***';
|
||||||
|
}
|
||||||
|
return `${mobile.slice(0, 4)}****${mobile.slice(-2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
private readPositiveInt(key: string, fallback: number): number {
|
private readPositiveInt(key: string, fallback: number): number {
|
||||||
const raw = this.config.get<string>(key);
|
const raw = this.config.get<string>(key);
|
||||||
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
||||||
|
|||||||
@@ -62,25 +62,56 @@ export class SmsService {
|
|||||||
|
|
||||||
const url = `${SMS_0098_SEND_URL}?${params.toString()}`;
|
const url = `${SMS_0098_SEND_URL}?${params.toString()}`;
|
||||||
|
|
||||||
|
const to = this.normalizePhoneForSms(phone);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const body = await res.text();
|
const body = (await res.text()).trim();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`SMS HTTP ${res.status}: ${body.slice(0, 300)}`,
|
`SMS HTTP ${res.status} to=${this.maskPhone(to)} body=${body.slice(0, 300)}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!this.isLikelySuccessBody(body)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`SMS provider rejected to=${this.maskPhone(to)} body=${body.slice(0, 300)}`,
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[SMS] request failed',
|
`SMS request failed to=${this.maskPhone(to)}`,
|
||||||
e instanceof Error ? e.stack : e,
|
e instanceof Error ? e.stack : e,
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 0098 often returns plain text; treat obvious error tokens as failure. */
|
||||||
|
private isLikelySuccessBody(body: string): boolean {
|
||||||
|
if (!body) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const lower = body.toLowerCase();
|
||||||
|
if (
|
||||||
|
lower.includes('error') ||
|
||||||
|
lower.includes('fail') ||
|
||||||
|
lower.includes('invalid')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private maskPhone(phone: string): string {
|
||||||
|
if (phone.length <= 6) {
|
||||||
|
return '***';
|
||||||
|
}
|
||||||
|
return `${phone.slice(0, 4)}****${phone.slice(-2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
private getPassword(): string | undefined {
|
private getPassword(): string | undefined {
|
||||||
return (
|
return (
|
||||||
this.config.get<string>('SMS_PASSWORD') ??
|
this.config.get<string>('SMS_PASSWORD') ??
|
||||||
|
|||||||
Reference in New Issue
Block a user