- 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>
253 lines
7.7 KiB
TypeScript
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();
|