import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; import { Types } from "mongoose"; import { UserAuthErrorCode, throwUserAuthError, } from "src/auth/auth-services/user-auth-error"; import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service"; import { LoginDtoRs } from "src/auth/dto/user/login.dto"; import { buildUserLookupByPhone, normalizeIranMobile, } from "src/helpers/iran-mobile"; import { computeOtpExpireMs, FAKE_OTP_CODE, isFakeOtpEnabled, isOtpExpiryActive, readOtpExpireMinutesFromEnv, } from "src/helpers/user-otp-expiry"; import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { HashService } from "src/utils/hash/hash.service"; export interface LinkBinding { linkToken?: string; linkContext?: string; } @Injectable() export class UserAuthService { private readonly logger = new Logger(UserAuthService.name); constructor( private readonly jwtService: JwtService, private readonly userDbService: UserDbService, private readonly hashService: HashService, private readonly otpCreator: OtpGeneratorService, private readonly smsOrchestrationService: SmsOrchestrationService, private readonly userLinkAccessService: UserLinkAccessService, ) {} async validateUser( username: string, pass: string, binding: LinkBinding = {}, ): Promise { const canonicalMobile = normalizeIranMobile(username) ?? username.trim(); await this.userLinkAccessService.assertMobileAllowed({ mobile: canonicalMobile, linkToken: binding.linkToken, linkContext: binding.linkContext, }); const user = await this.userDbService.findOne( buildUserLookupByPhone(canonicalMobile), ); if (!user) throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND); if (user.otp == null) throwUserAuthError(UserAuthErrorCode.OTP_REQUIRED); if (!isOtpExpiryActive(user.otpExpire)) { throwUserAuthError(UserAuthErrorCode.OTP_EXPIRED); } if (await this.hashService.compare(pass, user.otp)) { return user; } return false; } async login(user: any) { const userId = String(user._id ?? user.id ?? ""); const payload = { username: user.username, sub: userId, role: "user", }; const accToken = this.jwtService.sign(payload, { secret: `${process.env.JWT_SECRET}`, }); await this.userDbService.findOneAndUpdate( { username: user.username }, { tokens: { token: accToken }, otp: null, otpExpire: 0, }, ); return { userId, access_token: accToken, }; } async sendOtpRequest( mobile: string, binding: LinkBinding = {}, ): Promise { const canonicalMobile = normalizeIranMobile(mobile) ?? mobile.trim(); if (!canonicalMobile) { throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND); } await this.userLinkAccessService.assertMobileAllowed({ mobile: canonicalMobile, linkToken: binding.linkToken, linkContext: binding.linkContext, }); const userExist = await this.userDbService.findOne( buildUserLookupByPhone(canonicalMobile), ); const otp = this.createOtpForRequest(); const hashOtp = await this.hashService.hash(otp); const expireMinutes = readOtpExpireMinutesFromEnv(); const nowMs = Date.now(); const otpExpire = computeOtpExpireMs(expireMinutes, nowMs); if (!userExist) { await this.smsSender(otp, canonicalMobile); // console.log(`OTP for ${canonicalMobile}: ${otp}`); const newUser = await this.userDbService.createUser({ mobile: canonicalMobile, username: canonicalMobile, otp: hashOtp, tokens: { token: "", rfToken: "", }, fullName: "", nationalCode: "", lastLogin: new Date(), clientKey: new Types.ObjectId(), birthDay: "", city: "", address: "", state: "", otpExpire, }); return new LoginDtoRs(newUser); } if (isOtpExpiryActive(userExist.otpExpire, nowMs)) { // throwUserAuthError(UserAuthErrorCode.OTP_REQUEST_TOO_SOON); return new LoginDtoRs(userExist, "OTP Still valid"); } await this.smsSender(otp, canonicalMobile); // console.log(`OTP for ${canonicalMobile}: ${otp}`); await this.userDbService.findOneAndUpdate( buildUserLookupByPhone(canonicalMobile), { otp: hashOtp, otpExpire, mobile: canonicalMobile, username: userExist.username || canonicalMobile, }, ); return new LoginDtoRs(userExist); } private createOtpForRequest(): string { if (isFakeOtpEnabled()) { this.logger.warn( "FAKE_OTP=true — using fixed dev OTP; SMS provider is not called", ); return FAKE_OTP_CODE; } return this.otpCreator.create(); } private async smsSender(otp: string, mobile: string) { if (isFakeOtpEnabled()) { this.logger.log( `FAKE_OTP=true — skipped SMS for phone=${mobile} (use OTP ${FAKE_OTP_CODE})`, ); return; } const ok = await this.smsOrchestrationService.sendAuthOtp( mobile, otp, process.env.AUTH_SMS_TEMPLATE, ); if (!ok) { throw new HttpException("auth sms send failed", HttpStatus.BAD_GATEWAY); } this.logger.log( `Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`, ); } }