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
|
||||
OTP_HASH_SECRET=change_me_to_a_long_random_secret
|
||||
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
|
||||
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
|
||||
# From panel: account username, password (or web service password), approved sender line (FROM)
|
||||
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 { 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() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
configureCors(app);
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
forbidNonWhitelisted: true,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
@@ -28,8 +31,11 @@ type VerifyOtpResponse = {
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
private readonly otpTtlSeconds: number;
|
||||
private readonly maxAttempts: number;
|
||||
private readonly otpRequestCooldownSeconds: number;
|
||||
private readonly otpRequestMaxPerHour: number;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
@@ -42,10 +48,20 @@ export class AuthService {
|
||||
) {
|
||||
this.otpTtlSeconds = this.readPositiveInt('OTP_TTL_SECONDS', 120);
|
||||
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> {
|
||||
const mobile = this.normalizeMobile(dto.mobile);
|
||||
await this.assertOtpRequestAllowed(mobile);
|
||||
|
||||
const code = this.generateOtpCode();
|
||||
const expiresAt = new Date(Date.now() + this.otpTtlSeconds * 1000);
|
||||
|
||||
@@ -73,6 +89,9 @@ export class AuthService {
|
||||
}
|
||||
const sent = await this.sms.sendOtpSms(mobile, code);
|
||||
if (!sent) {
|
||||
this.logger.warn(
|
||||
`OTP SMS failed for ${this.maskMobile(mobile)}`,
|
||||
);
|
||||
throw new ServiceUnavailableException(
|
||||
'Could not send SMS. Please try again later.',
|
||||
);
|
||||
@@ -208,6 +227,46 @@ export class AuthService {
|
||||
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 {
|
||||
const raw = this.config.get<string>(key);
|
||||
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
||||
|
||||
@@ -62,25 +62,56 @@ export class SmsService {
|
||||
|
||||
const url = `${SMS_0098_SEND_URL}?${params.toString()}`;
|
||||
|
||||
const to = this.normalizePhoneForSms(phone);
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const body = await res.text();
|
||||
const body = (await res.text()).trim();
|
||||
if (!res.ok) {
|
||||
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 true;
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
'[SMS] request failed',
|
||||
`SMS request failed to=${this.maskPhone(to)}`,
|
||||
e instanceof Error ? e.stack : e,
|
||||
);
|
||||
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 {
|
||||
return (
|
||||
this.config.get<string>('SMS_PASSWORD') ??
|
||||
|
||||
Reference in New Issue
Block a user