mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-11-26 00:24:54 +00:00
293 lines
9.4 KiB
TypeScript
293 lines
9.4 KiB
TypeScript
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import * as bcrypt from 'bcrypt';
|
|
import { OtpScope, OtpType } from '~/common/modules/otp/enums';
|
|
import { OtpService } from '~/common/modules/otp/services';
|
|
import { JuniorTokenService } from '~/junior/services';
|
|
import { PASSCODE_REGEX, PASSWORD_REGEX } from '../constants';
|
|
import {
|
|
CreateUnverifiedUserRequestDto,
|
|
DisableBiometricRequestDto,
|
|
EnableBiometricRequestDto,
|
|
ForgetPasswordRequestDto,
|
|
LoginRequestDto,
|
|
SendForgetPasswordOtpRequestDto,
|
|
SetEmailRequestDto,
|
|
setJuniorPasswordRequestDto,
|
|
} from '../dtos/request';
|
|
import { VerifyUserRequestDto } from '../dtos/request/verify-user.request.dto';
|
|
import { User } from '../entities';
|
|
import { GrantType, Roles } from '../enums';
|
|
import { IJwtPayload, ILoginResponse } from '../interfaces';
|
|
import { removePadding, verifySignature } from '../utils';
|
|
import { DeviceService } from './device.service';
|
|
import { UserService } from './user.service';
|
|
|
|
const ONE_THOUSAND = 1000;
|
|
const SALT_ROUNDS = 10;
|
|
@Injectable()
|
|
export class AuthService {
|
|
constructor(
|
|
private readonly otpService: OtpService,
|
|
private readonly jwtService: JwtService,
|
|
private readonly configService: ConfigService,
|
|
private readonly userService: UserService,
|
|
private readonly deviceService: DeviceService,
|
|
private readonly juniorTokenService: JuniorTokenService,
|
|
) {}
|
|
async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
|
|
const user = await this.userService.findOrCreateUser({ phoneNumber, countryCode });
|
|
|
|
return this.otpService.generateAndSendOtp({
|
|
userId: user.id,
|
|
recipient: user.phoneNumber,
|
|
scope: OtpScope.VERIFY_PHONE,
|
|
otpType: OtpType.SMS,
|
|
});
|
|
}
|
|
|
|
async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> {
|
|
const user = await this.userService.findUserOrThrow({ phoneNumber: verifyUserDto.phoneNumber });
|
|
|
|
if (user.isPasswordSet) {
|
|
throw new BadRequestException('USERS.PHONE_ALREADY_VERIFIED');
|
|
}
|
|
|
|
const isOtpValid = await this.otpService.verifyOtp({
|
|
userId: user.id,
|
|
scope: OtpScope.VERIFY_PHONE,
|
|
otpType: OtpType.SMS,
|
|
value: verifyUserDto.otp,
|
|
});
|
|
|
|
if (!isOtpValid) {
|
|
throw new BadRequestException('USERS.INVALID_OTP');
|
|
}
|
|
|
|
const updatedUser = await this.userService.verifyUserAndCreateCustomer(user);
|
|
|
|
const tokens = await this.generateAuthToken(updatedUser);
|
|
|
|
return [tokens, updatedUser];
|
|
}
|
|
|
|
async setEmail(userId: string, { email }: SetEmailRequestDto) {
|
|
const user = await this.userService.findUserOrThrow({ id: userId });
|
|
|
|
if (user.email) {
|
|
throw new BadRequestException('USERS.EMAIL_ALREADY_SET');
|
|
}
|
|
|
|
const existingUser = await this.userService.findUser({ email });
|
|
|
|
if (existingUser) {
|
|
throw new BadRequestException('USERS.EMAIL_ALREADY_TAKEN');
|
|
}
|
|
|
|
return this.userService.setEmail(userId, email);
|
|
}
|
|
|
|
async setPasscode(userId: string, passcode: string) {
|
|
const user = await this.userService.findUserOrThrow({ id: userId });
|
|
|
|
if (user.password) {
|
|
throw new BadRequestException('USERS.PASSCODE_ALREADY_SET');
|
|
}
|
|
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
|
|
const hashedPasscode = bcrypt.hashSync(passcode, salt);
|
|
|
|
await this.userService.setPasscode(userId, hashedPasscode, salt);
|
|
}
|
|
|
|
async enableBiometric(userId: string, { deviceId, publicKey }: EnableBiometricRequestDto) {
|
|
const device = await this.deviceService.findUserDeviceById(deviceId, userId);
|
|
|
|
if (!device) {
|
|
return this.deviceService.createDevice({
|
|
deviceId,
|
|
userId,
|
|
publicKey,
|
|
});
|
|
}
|
|
|
|
if (device.publicKey) {
|
|
throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_ENABLED');
|
|
}
|
|
|
|
return this.deviceService.updateDevice(deviceId, { publicKey });
|
|
}
|
|
|
|
async disableBiometric(userId: string, { deviceId }: DisableBiometricRequestDto) {
|
|
const device = await this.deviceService.findUserDeviceById(deviceId, userId);
|
|
|
|
if (!device) {
|
|
throw new BadRequestException('AUTH.DEVICE_NOT_FOUND');
|
|
}
|
|
|
|
if (!device.publicKey) {
|
|
throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_DISABLED');
|
|
}
|
|
|
|
return this.deviceService.updateDevice(deviceId, { publicKey: null });
|
|
}
|
|
|
|
async sendForgetPasswordOtp({ email }: SendForgetPasswordOtpRequestDto) {
|
|
const user = await this.userService.findUserOrThrow({ email });
|
|
|
|
if (!user.isProfileCompleted) {
|
|
throw new BadRequestException('USERS.PROFILE_NOT_COMPLETED');
|
|
}
|
|
|
|
return this.otpService.generateAndSendOtp({
|
|
userId: user.id,
|
|
recipient: user.email,
|
|
scope: OtpScope.FORGET_PASSWORD,
|
|
otpType: OtpType.EMAIL,
|
|
});
|
|
}
|
|
|
|
async verifyForgetPasswordOtp({ email, otp, password, confirmPassword }: ForgetPasswordRequestDto) {
|
|
const user = await this.userService.findUserOrThrow({ email });
|
|
if (!user.isProfileCompleted) {
|
|
throw new BadRequestException('USERS.PROFILE_NOT_COMPLETED');
|
|
}
|
|
const isOtpValid = await this.otpService.verifyOtp({
|
|
userId: user.id,
|
|
scope: OtpScope.FORGET_PASSWORD,
|
|
otpType: OtpType.EMAIL,
|
|
value: otp,
|
|
});
|
|
|
|
if (!isOtpValid) {
|
|
throw new BadRequestException('USERS.INVALID_OTP');
|
|
}
|
|
|
|
this.validatePassword(password, confirmPassword, user);
|
|
|
|
const hashedPassword = bcrypt.hashSync(password, user.salt);
|
|
|
|
await this.userService.setPasscode(user.id, hashedPassword, user.salt);
|
|
}
|
|
|
|
async login(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> {
|
|
const user = await this.userService.findUser({ email: loginDto.email });
|
|
let tokens;
|
|
|
|
if (!user) {
|
|
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
|
|
}
|
|
|
|
if (loginDto.grantType === GrantType.PASSWORD) {
|
|
tokens = await this.loginWithPassword(loginDto, user);
|
|
} else {
|
|
tokens = await this.loginWithBiometric(loginDto, user, deviceId);
|
|
}
|
|
|
|
this.deviceService.updateDevice(deviceId, { lastAccessOn: new Date() });
|
|
|
|
return [tokens, user];
|
|
}
|
|
|
|
async setJuniorPasscode(body: setJuniorPasswordRequestDto) {
|
|
const juniorId = await this.juniorTokenService.validateToken(body.qrToken);
|
|
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
|
|
const hashedPasscode = bcrypt.hashSync(body.passcode, salt);
|
|
await this.userService.setPasscode(juniorId, hashedPasscode, salt);
|
|
await this.juniorTokenService.invalidateToken(body.qrToken);
|
|
}
|
|
|
|
async refreshToken(refreshToken: string): Promise<[ILoginResponse, User]> {
|
|
try {
|
|
const isValid = await this.jwtService.verifyAsync<IJwtPayload>(refreshToken, {
|
|
secret: this.configService.getOrThrow('JWT_REFRESH_TOKEN_SECRET'),
|
|
});
|
|
|
|
const user = await this.userService.findUserOrThrow({ id: isValid.sub });
|
|
|
|
const tokens = await this.generateAuthToken(user);
|
|
|
|
return [tokens, user];
|
|
} catch (error) {
|
|
throw new BadRequestException('AUTH.INVALID_REFRESH_TOKEN');
|
|
}
|
|
}
|
|
|
|
private async loginWithPassword(loginDto: LoginRequestDto, user: User): Promise<ILoginResponse> {
|
|
const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password);
|
|
|
|
if (!isPasswordValid) {
|
|
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
|
|
}
|
|
|
|
const tokens = await this.generateAuthToken(user);
|
|
|
|
return tokens;
|
|
}
|
|
|
|
private async loginWithBiometric(loginDto: LoginRequestDto, user: User, deviceId: string): Promise<ILoginResponse> {
|
|
const device = await this.deviceService.findUserDeviceById(deviceId, user.id);
|
|
|
|
if (!device) {
|
|
throw new UnauthorizedException('AUTH.DEVICE_NOT_FOUND');
|
|
}
|
|
|
|
if (!device.publicKey) {
|
|
throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED');
|
|
}
|
|
|
|
const cleanToken = removePadding(loginDto.deviceToken);
|
|
const isValidToken = await verifySignature(
|
|
device.publicKey,
|
|
cleanToken,
|
|
`${user.email} - ${device.deviceId}`,
|
|
'SHA1',
|
|
);
|
|
|
|
if (!isValidToken) {
|
|
throw new UnauthorizedException('AUTH.INVALID_BIOMETRIC');
|
|
}
|
|
|
|
const tokens = await this.generateAuthToken(user);
|
|
|
|
return tokens;
|
|
}
|
|
|
|
private async generateAuthToken(user: User) {
|
|
const [accessToken, refreshToken] = await Promise.all([
|
|
this.jwtService.sign(
|
|
{ sub: user.id, roles: user.roles },
|
|
{
|
|
expiresIn: this.configService.getOrThrow('JWT_ACCESS_TOKEN_EXPIRY'),
|
|
secret: this.configService.getOrThrow('JWT_ACCESS_TOKEN_SECRET'),
|
|
},
|
|
),
|
|
this.jwtService.sign(
|
|
{ sub: user.id, roles: user.roles },
|
|
{
|
|
expiresIn: this.configService.getOrThrow('JWT_REFRESH_TOKEN_EXPIRY'),
|
|
secret: this.configService.getOrThrow('JWT_REFRESH_TOKEN_SECRET'),
|
|
},
|
|
),
|
|
]);
|
|
|
|
return { accessToken, refreshToken, expiresAt: new Date(this.jwtService.decode(accessToken).exp * ONE_THOUSAND) };
|
|
}
|
|
|
|
private validatePassword(password: string, confirmPassword: string, user: User) {
|
|
if (password !== confirmPassword) {
|
|
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
|
|
}
|
|
|
|
const roles = user.roles;
|
|
|
|
if (roles.includes(Roles.GUARDIAN) && !PASSCODE_REGEX.test(password)) {
|
|
throw new BadRequestException('AUTH.INVALID_PASSCODE');
|
|
}
|
|
|
|
if (roles.includes(Roles.JUNIOR) && !PASSWORD_REGEX.test(password)) {
|
|
throw new BadRequestException('AUTH.INVALID_PASSWORD');
|
|
}
|
|
}
|
|
}
|