// auth.service import { envs } from '@/common/config/envs'; import { Env, validateString } from '@/common/utils'; import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider'; import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto'; import { SignInUserDto } from '@/features/auth/dto/signIn-user.dto'; import { SignOutUserDto } from '@/features/auth/dto/signOut-user.dto'; import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto'; import { ValidateUserDto } from '@/features/auth/dto/validate-user.dto'; import AuthTokensInterface from '@/features/auth/interfaces/auth-tokens.interface'; import { LoginUserInterface, Roles, } from '@/features/auth/interfaces/login-user.interface'; import RefreshTokenInterface from '@/features/auth/interfaces/refresh-token.interface'; import { MailService } from '@/features/mail/mail.service'; import { User } from '@/features/users/entities/user.entity'; import { HttpException, HttpStatus, Inject, Injectable, NotFoundException, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService, JwtSignOptions } from '@nestjs/jwt'; import * as bcrypt from 'bcryptjs'; import crypto from 'crypto'; import { eq, or } from 'drizzle-orm'; import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import * as schema from 'src/database/index'; import { roles, sessions, users, usersRole } from 'src/database/index'; import { Session } from './interfaces/session.interface'; @Injectable() export class AuthService { constructor( private readonly jwtService: JwtService, private readonly config: ConfigService, @Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase, private readonly mailService: MailService, ) { } //Decode Tokens // Método para decodificar el token y obtener los datos completos private decodeToken(token: string): { sub: number; username?: string; iat: number; exp: number; } { try { const decoded = this.jwtService.decode(token) as { sub: number; username?: string; iat: number; exp: number; }; // Validar que contiene los datos esenciales if (!decoded || !decoded.exp || !decoded.iat) { throw new Error('Token lacks required fields'); } return decoded; } catch (error) { // Manejo seguro del tipo unknown let errorMessage = 'Failed to decode token'; if (error instanceof Error) { errorMessage = error.message; console.error('Error decoding token:', errorMessage); } else { console.error('Unknown error type:', error); } throw new HttpException(errorMessage, HttpStatus.UNAUTHORIZED); } } //Generate Tokens async generateTokens(user: User): Promise { const accessTokenSecret = envs.access_token_secret ?? ''; const accessTokenExp = envs.access_token_expiration ?? ''; const refreshTokenSecret = envs.refresh_token_secret ?? ''; const refreshTokenExp = envs.refresh_token_expiration ?? ''; if ( !accessTokenSecret || !accessTokenExp || !refreshTokenSecret || !refreshTokenExp ) { throw new Error('JWT environment variables are missing or invalid'); } interface JwtPayload { sub: number; username: string; } const payload: JwtPayload = { sub: Number(user?.id), username: user.username ?? '', }; const [access_token, refresh_token] = await Promise.all([ this.jwtService.signAsync(payload, { secret: accessTokenSecret, expiresIn: accessTokenExp, } as JwtSignOptions), this.jwtService.signAsync(payload, { secret: refreshTokenSecret, expiresIn: refreshTokenExp, } as JwtSignOptions), ]); return { access_token, refresh_token }; } //Generate OTP Code For Email Confirmation async generateOTP(length = 6): Promise { return crypto .randomInt(0, 10 ** length) .toString() .padStart(length, '0'); } // metodo para crear una session private async createSession(sessionInput: Session): Promise { const { userId } = sessionInput; const activeSessionsCount = await this.drizzle .select() .from(sessions) .where(eq(sessions.userId, parseInt(userId))); if (activeSessionsCount.length !== 0) { // Elimina sessiones viejsas await this.drizzle .delete(sessions) .where(eq(sessions.userId, parseInt(userId))); } const session = await this.drizzle.insert(sessions).values({ sessionToken: sessionInput.sessionToken, userId: parseInt(userId), expiresAt: sessionInput.expiresAt, }); if (session.rowCount === 0) throw new HttpException('Failed to create session', HttpStatus.NOT_FOUND); return 'Session created successfully'; } //Find User async findUser(username: string): Promise { const user = await this.drizzle .select() .from(users) .where(eq(users.username, username)); return user[0]; } //Find User async findUserById(id: number): Promise { const user = await this.drizzle .select() .from(users) .where(eq(users.id, id)); return user[0]; } //Check User Is Already Exists async validateUser(dto: ValidateUserDto): Promise { const user = await this.findUser(dto.username); if (!user) throw new NotFoundException('User not found'); const isValid = await validateString( dto.password, user?.password as string, ); if (!isValid) throw new UnauthorizedException('Invalid credentials'); return user; } //Find rol user async findUserRol(id: number): Promise { const roles = await this.drizzle .select({ id: schema.roles.id, role: schema.roles.name, }) .from(schema.usersRole) .leftJoin(schema.roles, eq(schema.roles.id, schema.usersRole.roleId)) .where(eq(schema.usersRole.userId, id)); if (roles.length === 0) { throw new NotFoundException('User not found'); } // Aseguramos que no haya valores nulos return roles.map((role) => ({ id: role.id ?? 0, // Asignamos un valor por defecto (0) si es null rol: role.role ?? '', // Asignamos un valor por defecto (cadena vacía) si es null })); } //Sign In User Account async signIn(dto: SignInUserDto): Promise { const user = await this.validateUser(dto); const tokens = await this.generateTokens(user); const decodeAccess = this.decodeToken(tokens.access_token); const decodeRefresh = this.decodeToken(tokens.refresh_token); const rol = await this.findUserRol(user?.id as number); await this.createSession({ userId: String(user?.id), // Convert number to string sessionToken: tokens.refresh_token, expiresAt: decodeRefresh.exp, }); return { message: 'User signed in successfully', user: { id: user?.id as number, username: user?.username, fullname: user?.fullname, email: user?.email, rol: rol, }, tokens: { access_token: tokens.access_token, access_expire_in: decodeAccess.exp, refresh_token: tokens.refresh_token, refresh_expire_in: decodeRefresh.exp, }, }; } // //Forgot Password // async forgotPassword(dto: ForgotPasswordDto): Promise { // const user = await this.findUser(dto.username); // if (!user) throw new NotFoundException('User not found'); // const passwordResetToken = await this.generateOTP(); // user.passwordResetToken = passwordResetToken; // user.passwordResetTokenExpires = new Date( // Date.now() + 1000 * 60 * 60 * 24, // 1 day // ); // await this.UserRepository.save(user); // await this.mailService.sendEmail({ // to: [user.email], // subject: 'Reset Password', // html: ForgotPasswordMail({ // name: user.name, // code: passwordResetToken, // }), // }); // } //Sign Out User Account async signOut(dto: SignOutUserDto): Promise { const { user_id } = dto; const user = await this.drizzle .select() .from(users) .where(eq(users.id, parseInt(user_id))); if (!user) throw new NotFoundException('User not found'); await this.drizzle .delete(sessions) .where(eq(sessions.userId, parseInt(user_id))); } //Refresh User Access Token async refreshToken(dto: RefreshTokenDto): Promise { const { refreshToken } = dto; // 1. Validar firma del token (Crypto check) let payload: any; try { payload = await this.jwtService.verifyAsync(refreshToken, { secret: envs.refresh_token_secret, }); } catch (e) { throw new UnauthorizedException('Invalid Refresh Token Signature'); } const userId = Number(payload.sub); // Es más seguro usar el ID del token que el del DTO // 2. Buscar la sesión por UserID (SIN filtrar por token todavía) // Esto es clave: traemos la sesión para ver qué está pasando const [currentSession] = await this.drizzle .select() .from(sessions) .where(eq(sessions.userId, userId)); if (!currentSession) throw new NotFoundException('Session not found'); // CONFIGURACIÓN: Tiempo de gracia en milisegundos (ej: 15 segundos) const GRACE_PERIOD_MS = 15000; // ------------------------------------------------------------------- // ESCENARIO A: Rotación Normal (El token coincide con el actual) // ------------------------------------------------------------------- if (currentSession.sessionToken === refreshToken) { const user = await this.findUserById(userId); if (!user) throw new NotFoundException('User not found'); // Generar nuevos tokens (A -> B) const tokensNew = await this.generateTokens(user); const decodeAccess = this.decodeToken(tokensNew.access_token); const decodeRefresh = this.decodeToken(tokensNew.refresh_token); // Actualizamos DB guardando el token "viejo" como "previous" await this.drizzle .update(sessions) .set({ sessionToken: tokensNew.refresh_token, // Nuevo (B) previousSessionToken: refreshToken, // Viejo (A) lastRotatedAt: new Date(), // Marca de tiempo expiresAt: decodeRefresh.exp, }) .where(eq(sessions.userId, userId)); return { access_token: tokensNew.access_token, access_expire_in: decodeAccess.exp, refresh_token: tokensNew.refresh_token, refresh_expire_in: decodeRefresh.exp, }; } // ------------------------------------------------------------------- // ESCENARIO B: Periodo de Gracia (Condición de Carrera) // ------------------------------------------------------------------- // El token no coincide con el actual, ¿pero coincide con el anterior? const isPreviousToken = currentSession.previousSessionToken === refreshToken; // Calculamos cuánto tiempo ha pasado desde la rotación const timeSinceRotation = currentSession.lastRotatedAt ? Date.now() - new Date(currentSession.lastRotatedAt).getTime() : Infinity; if (isPreviousToken && timeSinceRotation < GRACE_PERIOD_MS) { // ¡Es una condición de carrera! El usuario envió 'A' pero ya rotamos a 'B'. // Le devolvemos 'B' (el actual en DB) para que se sincronice. const user = await this.findUserById(userId); if (!user) throw new NotFoundException('User not found'); // Generamos un access token nuevo fresco (barato) const accessTokenPayload = { sub: user.id, username: user.username }; const newAccessToken = await this.jwtService.signAsync( accessTokenPayload, { secret: envs.access_token_secret, expiresIn: envs.access_token_expiration, } as JwtSignOptions, ); const decodeAccess = this.decodeToken(newAccessToken); // IMPORTANTE: Devolvemos el refresh token QUE YA ESTÁ EN LA BASE DE DATOS // No generamos uno nuevo para no romper la cadena de la otra petición que ganó. return { access_token: newAccessToken, access_expire_in: decodeAccess.exp, refresh_token: currentSession.sessionToken!, // Devolvemos el token 'B' refresh_expire_in: currentSession.expiresAt as number, }; } // ------------------------------------------------------------------- // ESCENARIO C: Robo de Token (Reuse Detection) // ------------------------------------------------------------------- // Si el token no es el actual, ni el anterior válido... ALGUIEN LO ROBÓ. // O el usuario está intentando reusar un token muy viejo. // Medida de seguridad: Borrar todas las sesiones del usuario await this.drizzle.delete(sessions).where(eq(sessions.userId, userId)); throw new UnauthorizedException( 'Refresh token reuse detected. Access revoked.', ); } async singUp(createUserDto: SingUpUserDto): Promise { // Check if username or email exists const data = await this.drizzle .select({ id: users.id, username: users.username, email: users.email, }) .from(users) .where( or( eq(users.username, createUserDto.username), eq(users.email, createUserDto.email), ), ); if (data.length > 0) { if (data[0].username === createUserDto.username) { throw new HttpException( 'Username already exists', HttpStatus.BAD_REQUEST, ); } if (data[0].email === createUserDto.email) { throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST); } } const hashedPassword = await bcrypt.hash(createUserDto.password, 10); // Start a transaction return await this.drizzle.transaction(async (tx) => { // Hash the password // Create the user const [newUser] = await tx .insert(users) .values({ username: createUserDto.username, email: createUserDto.email, password: hashedPassword, fullname: createUserDto.fullname, isActive: true, state: createUserDto.state, municipality: createUserDto.municipality, parish: createUserDto.parish, phone: createUserDto.phone, isEmailVerified: false, isTwoFactorEnabled: false, }) .returning(); // check if user role is admin const role = createUserDto.role <= 2 ? 5 : createUserDto.role; // check if user role is admin // Assign role to user await tx.insert(usersRole).values({ userId: newUser.id, roleId: role, }); // Return the created user with role const [userWithRole] = await tx .select({ id: users.id, username: users.username, email: users.email, fullname: users.fullname, phone: users.phone, isActive: users.isActive, role: roles.name, }) .from(users) .leftJoin(usersRole, eq(usersRole.userId, users.id)) .leftJoin(roles, eq(roles.id, usersRole.roleId)) .where(eq(users.id, newUser.id)); return userWithRole; }); } }