2026-05-11 12:40:42 +03:30
|
|
|
import {
|
|
|
|
|
BadRequestException,
|
2026-05-18 14:37:02 +03:30
|
|
|
HttpException,
|
|
|
|
|
HttpStatus,
|
2026-05-11 12:40:42 +03:30
|
|
|
Injectable,
|
2026-05-18 14:37:02 +03:30
|
|
|
Logger,
|
2026-05-13 18:48:38 +03:30
|
|
|
ServiceUnavailableException,
|
2026-05-11 12:40:42 +03:30
|
|
|
UnauthorizedException,
|
|
|
|
|
} from '@nestjs/common';
|
|
|
|
|
import { ConfigService } from '@nestjs/config';
|
|
|
|
|
import { JwtService } from '@nestjs/jwt';
|
|
|
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
|
|
|
import { createHash, randomInt } from 'crypto';
|
|
|
|
|
import { IsNull, MoreThan, Repository } from 'typeorm';
|
2026-05-21 14:20:46 +03:30
|
|
|
import {
|
|
|
|
|
isValidIranMobile,
|
|
|
|
|
normalizeIranMobile,
|
|
|
|
|
} from '../../common/utils/iran-mobile.util';
|
2026-05-11 12:40:42 +03:30
|
|
|
import { AuthOtpEntity } from '../../entities/auth-otp.entity';
|
|
|
|
|
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';
|
2026-05-13 18:48:38 +03:30
|
|
|
import { SmsService } from './sms.service';
|
2026-05-11 12:40:42 +03:30
|
|
|
|
|
|
|
|
type RequestOtpResponse = {
|
|
|
|
|
expiresInSeconds: number;
|
|
|
|
|
otpCode?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type VerifyOtpResponse = {
|
|
|
|
|
accessToken: string;
|
|
|
|
|
user: Pick<UserEntity, 'id' | 'mobile' | 'fullName' | 'role' | 'isActive'>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class AuthService {
|
2026-05-18 14:37:02 +03:30
|
|
|
private readonly logger = new Logger(AuthService.name);
|
2026-05-11 12:40:42 +03:30
|
|
|
private readonly otpTtlSeconds: number;
|
|
|
|
|
private readonly maxAttempts: number;
|
2026-05-18 14:37:02 +03:30
|
|
|
private readonly otpRequestCooldownSeconds: number;
|
|
|
|
|
private readonly otpRequestMaxPerHour: number;
|
2026-05-11 12:40:42 +03:30
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private readonly config: ConfigService,
|
|
|
|
|
private readonly jwt: JwtService,
|
2026-05-13 18:48:38 +03:30
|
|
|
private readonly sms: SmsService,
|
2026-05-11 12:40:42 +03:30
|
|
|
@InjectRepository(AuthOtpEntity)
|
|
|
|
|
private readonly otps: Repository<AuthOtpEntity>,
|
|
|
|
|
@InjectRepository(UserEntity)
|
|
|
|
|
private readonly users: Repository<UserEntity>,
|
|
|
|
|
) {
|
|
|
|
|
this.otpTtlSeconds = this.readPositiveInt('OTP_TTL_SECONDS', 120);
|
|
|
|
|
this.maxAttempts = this.readPositiveInt('OTP_MAX_ATTEMPTS', 5);
|
2026-05-18 14:37:02 +03:30
|
|
|
this.otpRequestCooldownSeconds = this.readPositiveInt(
|
|
|
|
|
'OTP_REQUEST_COOLDOWN_SECONDS',
|
|
|
|
|
60,
|
|
|
|
|
);
|
|
|
|
|
this.otpRequestMaxPerHour = this.readPositiveInt(
|
|
|
|
|
'OTP_REQUEST_MAX_PER_HOUR',
|
|
|
|
|
5,
|
|
|
|
|
);
|
2026-05-11 12:40:42 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async requestOtp(dto: RequestOtpDto): Promise<RequestOtpResponse> {
|
|
|
|
|
const mobile = this.normalizeMobile(dto.mobile);
|
2026-05-18 14:37:02 +03:30
|
|
|
await this.assertOtpRequestAllowed(mobile);
|
|
|
|
|
|
2026-05-11 12:40:42 +03:30
|
|
|
const code = this.generateOtpCode();
|
|
|
|
|
const expiresAt = new Date(Date.now() + this.otpTtlSeconds * 1000);
|
|
|
|
|
|
|
|
|
|
await this.otps.update(
|
|
|
|
|
{ mobile, consumedAt: IsNull() },
|
|
|
|
|
{ consumedAt: new Date() },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await this.otps.save(
|
|
|
|
|
this.otps.create({
|
|
|
|
|
mobile,
|
|
|
|
|
codeHash: this.hashOtp(mobile, code),
|
|
|
|
|
expiresAt,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-13 18:48:38 +03:30
|
|
|
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) {
|
2026-05-18 14:37:02 +03:30
|
|
|
this.logger.warn(
|
|
|
|
|
`OTP SMS failed for ${this.maskMobile(mobile)}`,
|
|
|
|
|
);
|
2026-05-13 18:48:38 +03:30
|
|
|
throw new ServiceUnavailableException(
|
|
|
|
|
'Could not send SMS. Please try again later.',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 12:40:42 +03:30
|
|
|
const response: RequestOtpResponse = {
|
|
|
|
|
expiresInSeconds: this.otpTtlSeconds,
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-13 18:48:38 +03:30
|
|
|
if (returnOtpInResponse) {
|
2026-05-11 12:40:42 +03:30
|
|
|
response.otpCode = code;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async verifyOtp(dto: VerifyOtpDto): Promise<VerifyOtpResponse> {
|
|
|
|
|
const mobile = this.normalizeMobile(dto.mobile);
|
|
|
|
|
const code = String(dto.code ?? '').trim();
|
|
|
|
|
|
|
|
|
|
if (!/^\d{4,8}$/.test(code)) {
|
|
|
|
|
throw new BadRequestException('Invalid OTP code');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const otp = await this.otps.findOne({
|
|
|
|
|
where: {
|
|
|
|
|
mobile,
|
|
|
|
|
consumedAt: IsNull(),
|
|
|
|
|
expiresAt: MoreThan(new Date()),
|
|
|
|
|
},
|
|
|
|
|
order: { createdAt: 'DESC' },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!otp) {
|
|
|
|
|
throw new UnauthorizedException('OTP expired or not requested');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (otp.attempts >= this.maxAttempts) {
|
|
|
|
|
throw new UnauthorizedException('Too many OTP attempts');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (otp.codeHash !== this.hashOtp(mobile, code)) {
|
|
|
|
|
otp.attempts += 1;
|
|
|
|
|
await this.otps.save(otp);
|
|
|
|
|
throw new UnauthorizedException('Invalid OTP code');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
otp.consumedAt = new Date();
|
|
|
|
|
await this.otps.save(otp);
|
|
|
|
|
|
|
|
|
|
const user = await this.findOrCreateCustomer(mobile);
|
|
|
|
|
const payload: JwtPayload = {
|
|
|
|
|
sub: user.id,
|
|
|
|
|
mobile: user.mobile,
|
|
|
|
|
role: user.role,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
accessToken: await this.jwt.signAsync(payload),
|
|
|
|
|
user: this.serializeUser(user),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async me(userId: string) {
|
|
|
|
|
const user = await this.users.findOne({ where: { id: userId } });
|
|
|
|
|
if (!user) {
|
|
|
|
|
throw new UnauthorizedException('User not found');
|
|
|
|
|
}
|
|
|
|
|
return this.serializeUser(user);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async findOrCreateCustomer(mobile: string): Promise<UserEntity> {
|
|
|
|
|
const existing = await this.users.findOne({ where: { mobile } });
|
|
|
|
|
if (existing) {
|
|
|
|
|
if (!existing.isActive) {
|
|
|
|
|
throw new UnauthorizedException('User is inactive');
|
|
|
|
|
}
|
|
|
|
|
if (this.isConfiguredSuperAdminMobile(mobile) && existing.role !== UserRole.ADMIN) {
|
|
|
|
|
existing.role = UserRole.ADMIN;
|
|
|
|
|
return this.users.save(existing);
|
|
|
|
|
}
|
|
|
|
|
return existing;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.users.save(
|
|
|
|
|
this.users.create({
|
|
|
|
|
mobile,
|
|
|
|
|
fullName: null,
|
|
|
|
|
role: this.isConfiguredSuperAdminMobile(mobile)
|
|
|
|
|
? UserRole.ADMIN
|
|
|
|
|
: UserRole.CUSTOMER,
|
|
|
|
|
isActive: true,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private serializeUser(user: UserEntity) {
|
|
|
|
|
return {
|
|
|
|
|
id: user.id,
|
|
|
|
|
mobile: user.mobile,
|
|
|
|
|
fullName: user.fullName,
|
|
|
|
|
role: user.role,
|
|
|
|
|
isActive: user.isActive,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private normalizeMobile(mobile: string): string {
|
2026-05-21 14:20:46 +03:30
|
|
|
const normalized = normalizeIranMobile(mobile);
|
|
|
|
|
if (!isValidIranMobile(normalized)) {
|
2026-05-11 12:40:42 +03:30
|
|
|
throw new BadRequestException('Invalid mobile number');
|
|
|
|
|
}
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private generateOtpCode(): string {
|
|
|
|
|
return String(randomInt(100000, 1000000));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private hashOtp(mobile: string, code: string): string {
|
|
|
|
|
const pepper =
|
|
|
|
|
this.config.get<string>('OTP_HASH_SECRET') ??
|
|
|
|
|
this.config.get<string>('JWT_SECRET') ??
|
|
|
|
|
'montime-dev-secret-change-me';
|
|
|
|
|
|
|
|
|
|
return createHash('sha256')
|
|
|
|
|
.update(`${mobile}:${code}:${pepper}`)
|
|
|
|
|
.digest('hex');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private isConfiguredSuperAdminMobile(mobile: string): boolean {
|
|
|
|
|
const configured = this.config.get<string>('SUPER_ADMIN_MOBILE');
|
|
|
|
|
return configured ? this.normalizeMobile(configured) === mobile : false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 14:37:02 +03:30
|
|
|
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)}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 12:40:42 +03:30
|
|
|
private readPositiveInt(key: string, fallback: number): number {
|
|
|
|
|
const raw = this.config.get<string>(key);
|
|
|
|
|
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
|
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
|
|
|
}
|
|
|
|
|
}
|