mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2026-03-10 19:51:46 +00:00
- Introduced optional timezone fields in User and Device entities to store user preferences and device timezones. - Updated request DTOs for login and user updates to include timezone information. - Enhanced AuthService to handle timezone during device registration and updates. - Added migration to incorporate timezone fields in the database schema.
399 lines
16 KiB
TypeScript
399 lines
16 KiB
TypeScript
import { BadRequestException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import * as bcrypt from 'bcrypt';
|
|
import { Request } from 'express';
|
|
import moment from 'moment';
|
|
import { CacheService } from '~/common/modules/cache/services';
|
|
import { OtpScope, OtpType } from '~/common/modules/otp/enums';
|
|
import { OtpService } from '~/common/modules/otp/services';
|
|
import { UserType } from '~/user/enums';
|
|
import { DeviceService, UserService, UserTokenService } from '~/user/services';
|
|
import { User } from '../../user/entities';
|
|
import {
|
|
ChangePasswordRequestDto,
|
|
CreateUnverifiedUserRequestDto,
|
|
ForgetPasswordRequestDto,
|
|
JuniorLoginRequestDto,
|
|
LoginRequestDto,
|
|
SendForgetPasswordOtpRequestDto,
|
|
setJuniorPasswordRequestDto,
|
|
VerifyForgetPasswordOtpRequestDto,
|
|
VerifyUserRequestDto,
|
|
} from '../dtos/request';
|
|
import { Roles } from '../enums';
|
|
import { IJwtPayload, ILoginResponse } from '../interfaces';
|
|
|
|
const ONE_THOUSAND = 1000;
|
|
const SALT_ROUNDS = 10;
|
|
@Injectable()
|
|
export class AuthService {
|
|
private readonly logger = new Logger(AuthService.name);
|
|
|
|
constructor(
|
|
private readonly otpService: OtpService,
|
|
private readonly jwtService: JwtService,
|
|
private readonly configService: ConfigService,
|
|
private readonly userService: UserService,
|
|
private readonly deviceService: DeviceService,
|
|
private readonly userTokenService: UserTokenService,
|
|
private readonly cacheService: CacheService,
|
|
) {}
|
|
|
|
async sendRegisterOtp(body: CreateUnverifiedUserRequestDto) {
|
|
if (body.password !== body.confirmPassword) {
|
|
this.logger.error('Password and confirm password do not match');
|
|
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
|
|
}
|
|
|
|
this.logger.log(`Sending OTP to ${body.countryCode + body.phoneNumber}`);
|
|
const user = await this.userService.findOrCreateUser(body);
|
|
return this.otpService.generateAndSendOtp({
|
|
userId: user.id,
|
|
recipient: user.fullPhoneNumber,
|
|
scope: OtpScope.VERIFY_PHONE,
|
|
otpType: OtpType.SMS,
|
|
});
|
|
}
|
|
|
|
async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> {
|
|
this.logger.log(`Verifying user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`);
|
|
const user = await this.userService.findUserOrThrow({
|
|
phoneNumber: verifyUserDto.phoneNumber,
|
|
countryCode: verifyUserDto.countryCode,
|
|
});
|
|
|
|
if (user.isPhoneVerified) {
|
|
this.logger.error(`User with phone number ${user.fullPhoneNumber} already verified`);
|
|
throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_VERIFIED');
|
|
}
|
|
|
|
const isOtpValid = await this.otpService.verifyOtp({
|
|
userId: user.id,
|
|
scope: OtpScope.VERIFY_PHONE,
|
|
otpType: OtpType.SMS,
|
|
value: verifyUserDto.otp,
|
|
});
|
|
|
|
if (!isOtpValid) {
|
|
this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`);
|
|
throw new BadRequestException('OTP.INVALID_OTP');
|
|
}
|
|
|
|
await this.userService.verifyUser(user.id, verifyUserDto);
|
|
|
|
await user.reload();
|
|
|
|
const tokens = await this.generateAuthToken(user);
|
|
this.logger.log(`User with phone number ${user.fullPhoneNumber} verified successfully`);
|
|
|
|
// Register/update device with FCM token and timezone if provided
|
|
if (verifyUserDto.fcmToken && verifyUserDto.deviceId) {
|
|
await this.registerDeviceToken(user.id, verifyUserDto.deviceId, verifyUserDto.fcmToken, verifyUserDto.timezone);
|
|
}
|
|
|
|
return [tokens, user];
|
|
}
|
|
|
|
async sendForgetPasswordOtp({ countryCode, phoneNumber }: SendForgetPasswordOtpRequestDto) {
|
|
this.logger.log(`Sending forget password OTP to ${countryCode + phoneNumber}`);
|
|
const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber });
|
|
|
|
return this.otpService.generateAndSendOtp({
|
|
userId: user.id,
|
|
recipient: user.fullPhoneNumber,
|
|
scope: OtpScope.FORGET_PASSWORD,
|
|
otpType: OtpType.SMS,
|
|
});
|
|
}
|
|
|
|
async verifyForgetPasswordOtp({ countryCode, phoneNumber, otp }: VerifyForgetPasswordOtpRequestDto) {
|
|
const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber });
|
|
|
|
const isOtpValid = await this.otpService.verifyOtp({
|
|
userId: user.id,
|
|
scope: OtpScope.FORGET_PASSWORD,
|
|
otpType: OtpType.SMS,
|
|
value: otp,
|
|
});
|
|
|
|
if (!isOtpValid) {
|
|
this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`);
|
|
throw new BadRequestException('OTP.INVALID_OTP');
|
|
}
|
|
|
|
// generate a token for the user to reset password
|
|
const token = await this.userTokenService.generateToken(user.id, moment().add(5, 'minutes').toDate());
|
|
|
|
return { token, user };
|
|
}
|
|
|
|
async resetPassword({
|
|
countryCode,
|
|
phoneNumber,
|
|
resetPasswordToken,
|
|
password,
|
|
confirmPassword,
|
|
}: ForgetPasswordRequestDto) {
|
|
this.logger.log(`Verifying forget password OTP for ${countryCode + phoneNumber}`);
|
|
const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber });
|
|
await this.userTokenService.validateToken(
|
|
resetPasswordToken,
|
|
user.roles.includes(Roles.GUARDIAN) ? UserType.GUARDIAN : UserType.JUNIOR,
|
|
);
|
|
|
|
if (password !== confirmPassword) {
|
|
this.logger.error('Password and confirm password do not match');
|
|
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
|
|
}
|
|
|
|
const isOldPassword = bcrypt.compareSync(password, user.password);
|
|
|
|
if (isOldPassword) {
|
|
this.logger.error(
|
|
`New password cannot be the same as the current password for user with phone number ${user.fullPhoneNumber}`,
|
|
);
|
|
throw new BadRequestException('AUTH.PASSWORD_SAME_AS_CURRENT');
|
|
}
|
|
|
|
const hashedPassword = bcrypt.hashSync(password, user.salt);
|
|
|
|
await this.userService.setPassword(user.id, hashedPassword, user.salt);
|
|
await this.userTokenService.invalidateToken(resetPasswordToken);
|
|
this.logger.log(`Passcode updated successfully for user with phone number ${user.fullPhoneNumber}`);
|
|
}
|
|
|
|
async changePassword(userId: string, { currentPassword, newPassword, confirmNewPassword }: ChangePasswordRequestDto) {
|
|
const user = await this.userService.findUserOrThrow({ id: userId });
|
|
|
|
if (!user.isPasswordSet) {
|
|
this.logger.error(`Password not set for user with id ${userId}`);
|
|
throw new BadRequestException('AUTH.PASSWORD_NOT_SET');
|
|
}
|
|
|
|
if (currentPassword === newPassword) {
|
|
this.logger.error('New password cannot be the same as current password');
|
|
throw new BadRequestException('AUTH.PASSWORD_SAME_AS_CURRENT');
|
|
}
|
|
|
|
if (newPassword !== confirmNewPassword) {
|
|
this.logger.error('New password and confirm new password do not match');
|
|
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
|
|
}
|
|
|
|
this.logger.log(`Validating current password for user with id ${userId}`);
|
|
const isCurrentPasswordValid = bcrypt.compareSync(currentPassword, user.password);
|
|
|
|
if (!isCurrentPasswordValid) {
|
|
this.logger.error(`Invalid current password for user with id ${userId}`);
|
|
throw new UnauthorizedException('AUTH.INVALID_CURRENT_PASSWORD');
|
|
}
|
|
|
|
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
|
|
const hashedNewPassword = bcrypt.hashSync(newPassword, salt);
|
|
await this.userService.setPassword(user.id, hashedNewPassword, salt);
|
|
this.logger.log(`Password changed successfully for user with id ${userId}`);
|
|
}
|
|
|
|
async setJuniorPassword(body: setJuniorPasswordRequestDto) {
|
|
this.logger.log(`Setting passcode for junior with qrToken ${body.qrToken}`);
|
|
if (body.newPassword != body.confirmNewPassword) {
|
|
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
|
|
}
|
|
const juniorId = await this.userTokenService.validateToken(body.qrToken, UserType.JUNIOR);
|
|
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
|
|
const hashedPasscode = bcrypt.hashSync(body.newPassword, salt);
|
|
await this.userService.setPassword(juniorId!, hashedPasscode, salt);
|
|
await this.userTokenService.invalidateToken(body.qrToken);
|
|
this.logger.log(`Passcode set successfully for junior with id ${juniorId}`);
|
|
}
|
|
|
|
async refreshToken(refreshToken: string): Promise<[ILoginResponse, User]> {
|
|
this.logger.log('Refreshing token');
|
|
|
|
const isBlackListed = await this.cacheService.get(refreshToken);
|
|
|
|
if (isBlackListed) {
|
|
this.logger.error('Refresh token is blacklisted');
|
|
throw new BadRequestException('AUTH.INVALID_REFRESH_TOKEN');
|
|
}
|
|
|
|
try {
|
|
const isValid = await this.jwtService.verifyAsync<IJwtPayload>(refreshToken, {
|
|
secret: this.configService.getOrThrow('JWT_REFRESH_TOKEN_SECRET'),
|
|
});
|
|
|
|
this.logger.log(`Refreshing token for user with id ${isValid.sub}`);
|
|
|
|
const user = await this.userService.findUserOrThrow({ id: isValid.sub });
|
|
|
|
const tokens = await this.generateAuthToken(user);
|
|
|
|
this.logger.log(`Blacklisting old tokens for user with id ${isValid.sub}`);
|
|
|
|
const refreshTokenExpiry = this.jwtService.decode(refreshToken).exp - Date.now() / ONE_THOUSAND;
|
|
|
|
await this.cacheService.set(refreshToken, 'BLACKLISTED', refreshTokenExpiry);
|
|
|
|
this.logger.log(`Token refreshed successfully for user with id ${isValid.sub}`);
|
|
|
|
return [tokens, user];
|
|
} catch (error) {
|
|
this.logger.error('Invalid refresh token');
|
|
throw new BadRequestException('AUTH.INVALID_REFRESH_TOKEN');
|
|
}
|
|
}
|
|
|
|
logout(req: Request) {
|
|
this.logger.log('Logging out');
|
|
const accessToken = req.headers.authorization?.split(' ')[1] as string;
|
|
const expiryInTtl = this.jwtService.decode(accessToken).exp - Date.now() / ONE_THOUSAND;
|
|
return this.cacheService.set(accessToken, 'BLACKLISTED', expiryInTtl);
|
|
}
|
|
|
|
async loginWithPassword(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
|
|
const user = await this.userService.findUser({
|
|
countryCode: loginDto.countryCode,
|
|
phoneNumber: loginDto.phoneNumber,
|
|
});
|
|
|
|
if (!user) {
|
|
this.logger.error(`User not found with phone number ${loginDto.countryCode + loginDto.phoneNumber}`);
|
|
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
|
|
}
|
|
|
|
if (!user.password) {
|
|
this.logger.error(`Password not set for user with phone number ${loginDto.countryCode + loginDto.phoneNumber}`);
|
|
throw new UnauthorizedException('AUTH.PHONE_NUMBER_NOT_VERIFIED');
|
|
}
|
|
|
|
this.logger.log(`validating password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`);
|
|
const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password);
|
|
|
|
if (!isPasswordValid) {
|
|
this.logger.error(`Invalid password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`);
|
|
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
|
|
}
|
|
|
|
const tokens = await this.generateAuthToken(user);
|
|
this.logger.log(`Password validated successfully for user`);
|
|
|
|
// Register/update device with FCM token and timezone if provided
|
|
if (loginDto.fcmToken && loginDto.deviceId) {
|
|
await this.registerDeviceToken(user.id, loginDto.deviceId, loginDto.fcmToken, loginDto.timezone);
|
|
}
|
|
|
|
return [tokens, user];
|
|
}
|
|
|
|
async juniorLogin(juniorLoginDto: JuniorLoginRequestDto): Promise<[ILoginResponse, User]> {
|
|
const user = await this.userService.findUser({ email: juniorLoginDto.email });
|
|
|
|
if (!user || !user.roles.includes(Roles.JUNIOR)) {
|
|
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
|
|
}
|
|
|
|
this.logger.log(`validating password for user with email ${juniorLoginDto.email}`);
|
|
const isPasswordValid = bcrypt.compareSync(juniorLoginDto.password, user.password);
|
|
|
|
if (!isPasswordValid) {
|
|
this.logger.error(`Invalid password for user with email ${juniorLoginDto.email}`);
|
|
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
|
|
}
|
|
|
|
const tokens = await this.generateAuthToken(user);
|
|
this.logger.log(`Password validated successfully for user`);
|
|
|
|
// Register/update device with FCM token and timezone if provided
|
|
if (juniorLoginDto.fcmToken && juniorLoginDto.deviceId) {
|
|
await this.registerDeviceToken(user.id, juniorLoginDto.deviceId, juniorLoginDto.fcmToken, juniorLoginDto.timezone);
|
|
}
|
|
|
|
return [tokens, user];
|
|
}
|
|
|
|
/**
|
|
* Register or update device with FCM token and timezone
|
|
* This method handles:
|
|
* 1. Device already exists for this user → Update FCM token and timezone
|
|
* 2. Device exists for different user → Transfer device to new user
|
|
* 3. Device doesn't exist → Create new device
|
|
*/
|
|
private async registerDeviceToken(userId: string, deviceId: string, fcmToken: string, timezone?: string): Promise<void> {
|
|
try {
|
|
this.logger.log(`Registering/updating device ${deviceId} with FCM token for user ${userId}`);
|
|
|
|
// Step 1: Check if device already exists for this user
|
|
const existingDeviceForUser = await this.deviceService.findUserDeviceById(deviceId, userId);
|
|
|
|
if (existingDeviceForUser) {
|
|
// Device exists for this user → Update FCM token, timezone, and last access time
|
|
await this.deviceService.updateDevice(deviceId, {
|
|
fcmToken,
|
|
userId,
|
|
timezone, // Update timezone if provided
|
|
lastAccessOn: new Date(),
|
|
});
|
|
this.logger.log(`Device ${deviceId} updated with new FCM token and timezone for user ${userId}`);
|
|
return;
|
|
}
|
|
|
|
// Step 2: Check if device exists for any user (different user scenario)
|
|
const existingDevice = await this.deviceService.findByDeviceId(deviceId);
|
|
|
|
if (existingDevice) {
|
|
// Device exists for different user → Transfer device to new user
|
|
this.logger.log(
|
|
`Device ${deviceId} exists for user ${existingDevice.userId}, transferring to user ${userId}`
|
|
);
|
|
await this.deviceService.updateDevice(deviceId, {
|
|
userId,
|
|
fcmToken,
|
|
timezone, // Update timezone if provided
|
|
lastAccessOn: new Date(),
|
|
});
|
|
this.logger.log(`Device ${deviceId} transferred from user ${existingDevice.userId} to user ${userId}`);
|
|
return;
|
|
}
|
|
|
|
// Step 3: Device doesn't exist → Create new device
|
|
await this.deviceService.createDevice({
|
|
deviceId,
|
|
userId,
|
|
fcmToken,
|
|
timezone, // Store timezone if provided
|
|
lastAccessOn: new Date(),
|
|
});
|
|
this.logger.log(`New device ${deviceId} registered with FCM token for user ${userId}`);
|
|
} catch (error) {
|
|
// Log error but don't fail the login/signup process
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
this.logger.error(`Failed to register device token for user ${userId}: ${errorMessage}`, errorStack);
|
|
}
|
|
}
|
|
|
|
private async generateAuthToken(user: User) {
|
|
this.logger.log(`Generating auth token for user with id ${user.id}`);
|
|
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'),
|
|
},
|
|
),
|
|
]);
|
|
|
|
this.logger.log(`Auth token generated successfully for user with id ${user.id}`);
|
|
return { accessToken, refreshToken, expiresAt: new Date(this.jwtService.decode(accessToken).exp * ONE_THOUSAND) };
|
|
}
|
|
}
|