forked from Yara724/api
193 lines
5.6 KiB
TypeScript
193 lines
5.6 KiB
TypeScript
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<any> {
|
|
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<LoginDtoRs> {
|
|
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}`,
|
|
);
|
|
}
|
|
}
|