import * as bcrypt from 'bcrypt'; import { Injectable, UnauthorizedException, BadRequestException, NotFoundException, Logger } from '@nestjs/common'; import { RegisterDto } from '../dto/register.dto'; import { User } from 'src/users/entities/user.entity'; import { UsersService } from 'src/users/users.service'; import { ErrorCode } from 'src/common/enums/error-code.enum'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import { Session } from '../entities/session.entity'; import { PasswordResetToken } from '../entities/password-reset-token.entity'; import { VerificationCodeTypeEnum } from '../entities/verification-code.entity'; import { Request } from 'express'; import { randomBytes } from 'crypto'; import { MailService } from 'src/mail/mail.service'; import { pick } from 'src/common/utils/object.util'; import { CustomJwtService } from './jwt.service'; import { DbTransactionFactory, updateWithTransactions } from 'src/database/transaction-factory'; import { VerificationCodeService } from './verification-code.service'; type TokenPayload = Pick; @Injectable() export class AuthService { private readonly _logger = new Logger(AuthService.name); private readonly MAX_ACTIVE_TOKENS: number = 5; constructor( @InjectRepository(Session) private readonly refreshTokenRepository: Repository, @InjectRepository(PasswordResetToken) private readonly passwordResetTokenRepository: Repository, @InjectRepository(User) private userRepository: Repository, private readonly usersService: UsersService, private readonly jwtService: CustomJwtService, private readonly configService: ConfigService, private readonly mailService: MailService, private readonly verificationCodeService: VerificationCodeService, private readonly transactionRunner: DbTransactionFactory, ) {} private createTokens(payload: TokenPayload) { const accessToken = this.jwtService.createAccessToken(payload); const refreshToken = this.jwtService.createRefreshToken(payload); return { accessToken, refreshToken }; } private saveRefreshToken(user: User, token: string, deviceInfo: string, ipAddress: string) { return this.refreshTokenRepository.save({ refresh_token: token, user_id: user.id, device_info: deviceInfo, ip_address: ipAddress, expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days }); } private async deactivateOldTokens(userId: number) { const activeTokens = await this.refreshTokenRepository.find({ where: { user_id: userId, is_active: true }, order: { created_at: 'DESC' }, }); if (activeTokens.length >= this.MAX_ACTIVE_TOKENS) { const tokensToDeactivate = activeTokens.slice(this.MAX_ACTIVE_TOKENS - 1); await this.refreshTokenRepository.update( { id: In(tokensToDeactivate.map((t) => t.id)) }, { is_active: false }, ); } } async login(email: string, password: string, req: Request): Promise<{ accessToken: string; refreshToken: string }> { const user = await this.usersService.findOneByEmailWithPassword(email); const isMatch = await bcrypt.compare(password, user.password.value); if (!isMatch) { throw new UnauthorizedException(ErrorCode.WrongPassword); } if (!user.is_active) { throw new UnauthorizedException(ErrorCode.BlockedUser); } if (!user.is_verified) { throw new UnauthorizedException(ErrorCode.UserNotVerified); } const tokens = this.createTokens({ id: user.id, first_name: user.first_name, last_name: user.last_name, email: user.email, }); const deviceInfo = req.headers['user-agent'] || 'unknown'; const ipAddress = req.ip; await this.deactivateOldTokens(user.id); await this.saveRefreshToken(user, tokens.refreshToken, deviceInfo, ipAddress); return tokens; } async refreshToken(refreshToken: string, req: Request): Promise<{ accessToken: string; refreshToken: string }> { try { const payload = await this.jwtService.verifyRefreshToken(refreshToken); const storedToken = await this.refreshTokenRepository.findOne({ where: { refresh_token: refreshToken, is_active: true }, relations: ['user'], }); if (!storedToken) { throw new UnauthorizedException(ErrorCode.InvalidRefreshToken); } const deviceInfo = req.headers['user-agent'] || 'unknown'; const ipAddress = req.ip || req.connection.remoteAddress; if (storedToken.device_info !== deviceInfo || storedToken.ip_address !== ipAddress) { await this.refreshTokenRepository.update(storedToken.id, { is_active: false }); throw new UnauthorizedException(ErrorCode.InvalidRefreshToken); } const tokens = this.createTokens({ id: payload.id, first_name: payload.first_name, last_name: payload.last_name, email: payload.email, }); await this.refreshTokenRepository.update(storedToken.id, { is_active: false }); await this.saveRefreshToken(storedToken.user, tokens.refreshToken, deviceInfo, ipAddress); return tokens; } catch (error) { this._logger.error(error); throw new UnauthorizedException(ErrorCode.InvalidRefreshToken); } } async logout(refreshToken: string): Promise { await this.refreshTokenRepository.update({ refresh_token: refreshToken }, { is_active: false }); } async register({ email, first_name, last_name, password }: RegisterDto): Promise> { const existingUser = await this.usersService.findOneByEmail(email); if (existingUser) { throw new BadRequestException(ErrorCode.UserAlreadyExists); } const hashedPassword = await bcrypt.hash(password, 10); const { id } = await this.usersService.create({ email, first_name, last_name, password: hashedPassword, is_verified: false, }); const newUser = await this.usersService.findOne(id); const code = await this.verificationCodeService.createVerificationCode(email, VerificationCodeTypeEnum.Email); await this.mailService.sendVerificationCodeEmail(email, code); return pick(newUser, ['email', 'is_verified']); } async requestPasswordReset(email: string): Promise { const user = await this.usersService.findOneByEmail(email); if (!user) { return; } await this.passwordResetTokenRepository.update({ user_id: user.id, is_active: true }, { is_active: false }); const token = randomBytes(32).toString('hex'); const resetToken = this.passwordResetTokenRepository.create({ token, user_id: user.id, expires_at: new Date(Date.now() + 60 * 60 * 1000), // 1 hour }); await this.passwordResetTokenRepository.save(resetToken); const resetUrl = `${this.configService.get('FRONTEND_URL')}/auth/reset-password?token=${token}`; await this.mailService.sendPasswordResetEmail(user.email, resetUrl); } async resetPassword(token: string, newPassword: string): Promise { const resetToken = await this.passwordResetTokenRepository.findOne({ where: { token, is_active: true }, relations: ['user'], }); if (!resetToken || resetToken.expires_at < new Date()) { throw new UnauthorizedException(ErrorCode.InvalidResetToken); } const salt = await bcrypt.genSalt(); const hashedPassword = await bcrypt.hash(newPassword, salt); await this.usersService.updatePassword(resetToken.user.id, hashedPassword); await this.passwordResetTokenRepository.update(resetToken.id, { is_active: false }); } async resendVerificationCode(email: string): Promise { const user = await this.usersService.findOneByEmail(email); if (!user) { throw new NotFoundException(ErrorCode.UserNotFound); } if (user.is_verified) { throw new BadRequestException(ErrorCode.UserAlreadyExists); } const code = await this.verificationCodeService.createVerificationCode(email, VerificationCodeTypeEnum.Email); await this.mailService.sendVerificationCodeEmail(email, code); } async verifyEmail(email: string, code: string, req: Request): Promise<{ accessToken: string; refreshToken: string }> { const transactionalRunner = await this.transactionRunner.createTransaction(); try { await transactionalRunner.startTransaction(); const user = await this.usersService.findOneByEmail(email); if (!user) { throw new NotFoundException(ErrorCode.UserNotFound); } if (user.is_verified) { throw new BadRequestException(ErrorCode.UserAlreadyExists); } await this.verificationCodeService.verifyCode( email, code, VerificationCodeTypeEnum.Email, transactionalRunner.transactionManager, ); await updateWithTransactions.call( this.userRepository, { id: user.id }, { is_verified: true }, transactionalRunner.transactionManager, ); const tokens = this.createTokens({ id: user.id, first_name: user.first_name, last_name: user.last_name, email: user.email, }); const deviceInfo = req.headers['user-agent'] || 'unknown'; const ipAddress = req.ip; await this.deactivateOldTokens(user.id); await this.saveRefreshToken(user, tokens.refreshToken, deviceInfo, ipAddress); await transactionalRunner.commitTransaction(); return tokens; } catch (error) { await transactionalRunner.rollbackTransaction(); this._logger.error(error.message, error.stack); throw error; } finally { await transactionalRunner.releaseTransaction(); } } }