469 lines
15 KiB
TypeScript
469 lines
15 KiB
TypeScript
// 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<Env>,
|
|
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
|
|
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<AuthTokensInterface> {
|
|
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<string> {
|
|
return crypto
|
|
.randomInt(0, 10 ** length)
|
|
.toString()
|
|
.padStart(length, '0');
|
|
}
|
|
|
|
// metodo para crear una session
|
|
private async createSession(sessionInput: Session): Promise<string> {
|
|
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<User | null> {
|
|
const user = await this.drizzle
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.username, username));
|
|
return user[0];
|
|
}
|
|
|
|
//Find User
|
|
async findUserById(id: number): Promise<User | null> {
|
|
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<User> {
|
|
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<Roles[]> {
|
|
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<LoginUserInterface> {
|
|
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<void> {
|
|
// 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<void> {
|
|
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<RefreshTokenInterface> {
|
|
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<User> {
|
|
// 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;
|
|
});
|
|
}
|
|
}
|