erp-core-backend-v2/src/modules/auth/services/auth.service.ts
Adrian Flores Cortes 390bdd3923 [SYNC] feat: Add audit, MFA, and feature flags modules
- Add audit controller and routes
- Add audit middleware and services
- Add MFA controller, routes and service
- Add feature flags controller, routes, middleware and services
- Update package.json dependencies
- Update app.ts with new modules

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 08:02:02 -06:00

253 lines
7.7 KiB
TypeScript

import bcrypt from 'bcryptjs';
import { Repository } from 'typeorm';
import { AppDataSource } from '../../../config/typeorm.js';
import { User, UserStatus, Role } from '../entities/index.js';
import { tokenService, TokenPair, RequestMetadata } from './token.service.js';
import { UnauthorizedError, ValidationError, NotFoundError } from '../../../shared/types/index.js';
import { logger } from '../../../shared/utils/logger.js';
import { MfaService } from './mfa.service.js';
export interface LoginDto {
email: string;
password: string;
mfaCode?: string;
metadata?: RequestMetadata; // IP and user agent for session tracking
}
export interface RegisterDto {
email: string;
password: string;
// Soporta ambos formatos para compatibilidad frontend/backend
full_name?: string;
firstName?: string;
lastName?: string;
tenant_id?: string;
companyName?: string;
}
/**
* Transforma full_name a firstName/lastName para respuesta al frontend
*/
export function splitFullName(fullName: string): { firstName: string; lastName: string } {
const parts = fullName.trim().split(/\s+/);
if (parts.length === 1) {
return { firstName: parts[0], lastName: '' };
}
const firstName = parts[0];
const lastName = parts.slice(1).join(' ');
return { firstName, lastName };
}
/**
* Transforma firstName/lastName a full_name para almacenar en BD
*/
export function buildFullName(firstName?: string, lastName?: string, fullName?: string): string {
if (fullName) return fullName.trim();
return `${firstName || ''} ${lastName || ''}`.trim();
}
export interface LoginResponse {
user: Omit<User, 'passwordHash'> & { firstName: string; lastName: string };
tokens: TokenPair;
}
class AuthService {
private userRepository: Repository<User>;
constructor() {
this.userRepository = AppDataSource.getRepository(User);
}
async login(dto: LoginDto): Promise<LoginResponse> {
// Find user by email using TypeORM
const user = await this.userRepository.findOne({
where: { email: dto.email.toLowerCase(), status: UserStatus.ACTIVE },
relations: ['roles'],
});
if (!user) {
throw new UnauthorizedError('Credenciales inválidas');
}
// Verify password
const isValidPassword = await bcrypt.compare(dto.password, user.passwordHash || '');
if (!isValidPassword) {
throw new UnauthorizedError('Credenciales inválidas');
}
// MFA Verification
if (user.mfaEnabled) {
if (!dto.mfaCode) {
throw new ValidationError('MFA Code Required', [{
code: 'mfa_required',
message: 'Multi-factor authentication code is required',
path: ['mfaCode']
}]);
}
const isMfaValid = await MfaService.verifyMfaCode(user.id, dto.mfaCode);
if (!isMfaValid) {
throw new UnauthorizedError('Invalid MFA code');
}
}
// Update last login
user.lastLoginAt = new Date();
user.loginCount += 1;
if (dto.metadata?.ipAddress) {
user.lastLoginIp = dto.metadata.ipAddress;
}
await this.userRepository.save(user);
// Generate token pair using TokenService
const metadata: RequestMetadata = dto.metadata || {
ipAddress: 'unknown',
userAgent: 'unknown',
};
const tokens = await tokenService.generateTokenPair(user, metadata);
// Transform fullName to firstName/lastName for frontend response
const { firstName, lastName } = splitFullName(user.fullName);
// Remove passwordHash from response and add firstName/lastName
const { passwordHash, ...userWithoutPassword } = user;
const userResponse = {
...userWithoutPassword,
firstName,
lastName,
};
logger.info('User logged in', { userId: user.id, email: user.email, mfa: user.mfaEnabled });
return {
user: userResponse as any,
tokens,
};
}
async register(dto: RegisterDto): Promise<LoginResponse> {
// Check if email already exists using TypeORM
const existingUser = await this.userRepository.findOne({
where: { email: dto.email.toLowerCase() },
});
if (existingUser) {
throw new ValidationError('El email ya está registrado');
}
// Transform firstName/lastName to fullName for database storage
const fullName = buildFullName(dto.firstName, dto.lastName, dto.full_name);
// Hash password
const passwordHash = await bcrypt.hash(dto.password, 10);
// Generate tenantId if not provided (new company registration)
const tenantId = dto.tenant_id || crypto.randomUUID();
// Create user using TypeORM
const newUser = this.userRepository.create({
email: dto.email.toLowerCase(),
passwordHash,
fullName,
tenantId,
status: UserStatus.ACTIVE,
});
await this.userRepository.save(newUser);
// Load roles relation for token generation
const userWithRoles = await this.userRepository.findOne({
where: { id: newUser.id },
relations: ['roles'],
});
if (!userWithRoles) {
throw new Error('Error al crear usuario');
}
// Generate token pair using TokenService
const metadata: RequestMetadata = {
ipAddress: 'unknown',
userAgent: 'unknown',
};
const tokens = await tokenService.generateTokenPair(userWithRoles, metadata);
// Transform fullName to firstName/lastName for frontend response
const { firstName, lastName } = splitFullName(userWithRoles.fullName);
// Remove passwordHash from response and add firstName/lastName
const { passwordHash: _, ...userWithoutPassword } = userWithRoles;
const userResponse = {
...userWithoutPassword,
firstName,
lastName,
};
logger.info('User registered', { userId: userWithRoles.id, email: userWithRoles.email });
return {
user: userResponse as any,
tokens,
};
}
async refreshToken(refreshToken: string, metadata: RequestMetadata): Promise<TokenPair> {
// Delegate completely to TokenService
return tokenService.refreshTokens(refreshToken, metadata);
}
async logout(sessionId: string): Promise<void> {
await tokenService.revokeSession(sessionId, 'user_logout');
}
async logoutAll(userId: string): Promise<number> {
return tokenService.revokeAllUserSessions(userId, 'logout_all');
}
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<void> {
// Find user using TypeORM
const user = await this.userRepository.findOne({
where: { id: userId },
});
if (!user) {
throw new NotFoundError('Usuario no encontrado');
}
// Verify current password
const isValidPassword = await bcrypt.compare(currentPassword, user.passwordHash || '');
if (!isValidPassword) {
throw new UnauthorizedError('Contraseña actual incorrecta');
}
// Hash new password and update user
const newPasswordHash = await bcrypt.hash(newPassword, 10);
user.passwordHash = newPasswordHash;
user.updatedAt = new Date();
await this.userRepository.save(user);
// Revoke all sessions after password change for security
const revokedCount = await tokenService.revokeAllUserSessions(userId, 'password_changed');
logger.info('Password changed and all sessions revoked', { userId, revokedCount });
}
async getProfile(userId: string): Promise<Omit<User, 'passwordHash'>> {
// Find user using TypeORM with relations
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['roles', 'companies'],
});
if (!user) {
throw new NotFoundError('Usuario no encontrado');
}
// Remove passwordHash from response
const { passwordHash, ...userWithoutPassword } = user;
return userWithoutPassword;
}
}
export const authService = new AuthService();