erp-suite/apps/shared-libs/core/services/auth.service.ts

420 lines
10 KiB
TypeScript

/**
* AuthService
*
* @description Centralized authentication service for ERP-Suite.
* Moved from erp-core to shared-libs (P0-014).
*
* Features:
* - Email/password login
* - User registration with multi-tenancy support
* - JWT token generation and refresh
* - Password change
* - Profile retrieval
*
* @example
* ```typescript
* import { AuthService, createAuthService } from '@erp-suite/core';
*
* const authService = createAuthService({
* jwtSecret: process.env.JWT_SECRET,
* jwtExpiresIn: '1h',
* queryFn: myQueryFunction,
* });
*
* const result = await authService.login({ email, password });
* ```
*/
import bcrypt from 'bcryptjs';
import jwt, { SignOptions } from 'jsonwebtoken';
/**
* Login data transfer object
*/
export interface LoginDto {
email: string;
password: string;
}
/**
* Registration data transfer object
*/
export interface RegisterDto {
email: string;
password: string;
full_name?: string;
firstName?: string;
lastName?: string;
tenant_id?: string;
companyName?: string;
}
/**
* JWT payload structure
*/
export interface JwtPayload {
userId: string;
tenantId: string;
email: string;
roles: string[];
iat?: number;
exp?: number;
}
/**
* Auth tokens response
*/
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: string;
}
/**
* User entity (without password)
*/
export interface AuthUser {
id: string;
tenant_id: string;
email: string;
full_name: string;
firstName?: string;
lastName?: string;
status: string;
role_codes?: string[];
created_at: Date;
last_login_at?: Date;
}
/**
* Internal user with password
*/
interface InternalUser extends AuthUser {
password_hash: string;
}
/**
* Login response
*/
export interface LoginResponse {
user: AuthUser;
tokens: AuthTokens;
}
/**
* Query function type for database operations
*/
export type QueryFn<T = unknown> = (sql: string, params: unknown[]) => Promise<T[]>;
export type QueryOneFn<T = unknown> = (sql: string, params: unknown[]) => Promise<T | null>;
/**
* Logger interface
*/
export interface AuthLogger {
info: (message: string, meta?: Record<string, unknown>) => void;
error: (message: string, meta?: Record<string, unknown>) => void;
warn: (message: string, meta?: Record<string, unknown>) => void;
}
/**
* Auth service configuration
*/
export interface AuthServiceConfig {
jwtSecret: string;
jwtExpiresIn: string;
jwtRefreshExpiresIn: string;
queryOne: QueryOneFn<InternalUser>;
query: QueryFn;
logger?: AuthLogger;
}
/**
* Error types for auth operations
*/
export class AuthUnauthorizedError extends Error {
constructor(message: string) {
super(message);
this.name = 'UnauthorizedError';
}
}
export class AuthValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}
export class AuthNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'NotFoundError';
}
}
/**
* Transforms full_name to firstName/lastName
*/
export function splitFullName(fullName: string): { firstName: string; lastName: string } {
const parts = (fullName || '').trim().split(/\s+/);
if (parts.length === 0 || parts[0] === '') {
return { firstName: '', lastName: '' };
}
if (parts.length === 1) {
return { firstName: parts[0], lastName: '' };
}
const firstName = parts[0];
const lastName = parts.slice(1).join(' ');
return { firstName, lastName };
}
/**
* Transforms firstName/lastName to full_name
*/
export function buildFullName(
firstName?: string,
lastName?: string,
fullName?: string,
): string {
if (fullName) return fullName.trim();
return `${firstName || ''} ${lastName || ''}`.trim();
}
/**
* Centralized Auth Service for ERP-Suite
*/
export class AuthService {
private readonly config: AuthServiceConfig;
private readonly logger: AuthLogger;
constructor(config: AuthServiceConfig) {
this.config = config;
this.logger = config.logger || {
info: console.log,
error: console.error,
warn: console.warn,
};
}
/**
* Login with email/password
*/
async login(dto: LoginDto): Promise<LoginResponse> {
const user = await this.config.queryOne(
`SELECT u.*, array_agg(r.code) as role_codes
FROM auth.users u
LEFT JOIN auth.user_roles ur ON u.id = ur.user_id
LEFT JOIN auth.roles r ON ur.role_id = r.id
WHERE u.email = $1 AND u.status = 'active'
GROUP BY u.id`,
[dto.email.toLowerCase()],
);
if (!user) {
throw new AuthUnauthorizedError('Credenciales invalidas');
}
const isValidPassword = await bcrypt.compare(
dto.password,
user.password_hash || '',
);
if (!isValidPassword) {
throw new AuthUnauthorizedError('Credenciales invalidas');
}
// Update last login
await this.config.query(
'UPDATE auth.users SET last_login_at = NOW() WHERE id = $1',
[user.id],
);
const tokens = this.generateTokens(user);
const userResponse = this.formatUserResponse(user);
this.logger.info('User logged in', { userId: user.id, email: user.email });
return { user: userResponse, tokens };
}
/**
* Register new user
*/
async register(dto: RegisterDto): Promise<LoginResponse> {
const existingUser = await this.config.queryOne(
'SELECT id FROM auth.users WHERE email = $1',
[dto.email.toLowerCase()],
);
if (existingUser) {
throw new AuthValidationError('El email ya esta registrado');
}
const fullName = buildFullName(dto.firstName, dto.lastName, dto.full_name);
const password_hash = await bcrypt.hash(dto.password, 10);
const tenantId = dto.tenant_id || crypto.randomUUID();
const newUser = await this.config.queryOne(
`INSERT INTO auth.users (tenant_id, email, password_hash, full_name, status, created_at)
VALUES ($1, $2, $3, $4, 'active', NOW())
RETURNING *`,
[tenantId, dto.email.toLowerCase(), password_hash, fullName],
);
if (!newUser) {
throw new Error('Error al crear usuario');
}
const tokens = this.generateTokens(newUser);
const userResponse = this.formatUserResponse(newUser);
this.logger.info('User registered', { userId: newUser.id, email: newUser.email });
return { user: userResponse, tokens };
}
/**
* Refresh access token
*/
async refreshToken(refreshToken: string): Promise<AuthTokens> {
try {
const payload = jwt.verify(
refreshToken,
this.config.jwtSecret,
) as JwtPayload;
const user = await this.config.queryOne(
'SELECT * FROM auth.users WHERE id = $1 AND status = $2',
[payload.userId, 'active'],
);
if (!user) {
throw new AuthUnauthorizedError('Usuario no encontrado o inactivo');
}
return this.generateTokens(user);
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new AuthUnauthorizedError('Refresh token expirado');
}
throw new AuthUnauthorizedError('Refresh token invalido');
}
}
/**
* Change user password
*/
async changePassword(
userId: string,
currentPassword: string,
newPassword: string,
): Promise<void> {
const user = await this.config.queryOne(
'SELECT * FROM auth.users WHERE id = $1',
[userId],
);
if (!user) {
throw new AuthNotFoundError('Usuario no encontrado');
}
const isValidPassword = await bcrypt.compare(
currentPassword,
user.password_hash || '',
);
if (!isValidPassword) {
throw new AuthUnauthorizedError('Contrasena actual incorrecta');
}
const newPasswordHash = await bcrypt.hash(newPassword, 10);
await this.config.query(
'UPDATE auth.users SET password_hash = $1, updated_at = NOW() WHERE id = $2',
[newPasswordHash, userId],
);
this.logger.info('Password changed', { userId });
}
/**
* Get user profile
*/
async getProfile(userId: string): Promise<AuthUser> {
const user = await this.config.queryOne(
`SELECT u.*, array_agg(r.code) as role_codes
FROM auth.users u
LEFT JOIN auth.user_roles ur ON u.id = ur.user_id
LEFT JOIN auth.roles r ON ur.role_id = r.id
WHERE u.id = $1
GROUP BY u.id`,
[userId],
);
if (!user) {
throw new AuthNotFoundError('Usuario no encontrado');
}
return this.formatUserResponse(user);
}
/**
* Verify JWT token
*/
verifyToken(token: string): JwtPayload {
try {
return jwt.verify(token, this.config.jwtSecret) as JwtPayload;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new AuthUnauthorizedError('Token expirado');
}
throw new AuthUnauthorizedError('Token invalido');
}
}
/**
* Generate JWT tokens
*/
private generateTokens(user: InternalUser): AuthTokens {
const payload: JwtPayload = {
userId: user.id,
tenantId: user.tenant_id,
email: user.email,
roles: user.role_codes || [],
};
const accessToken = jwt.sign(payload, this.config.jwtSecret, {
expiresIn: this.config.jwtExpiresIn,
} as SignOptions);
const refreshToken = jwt.sign(payload, this.config.jwtSecret, {
expiresIn: this.config.jwtRefreshExpiresIn,
} as SignOptions);
return {
accessToken,
refreshToken,
expiresIn: this.config.jwtExpiresIn,
};
}
/**
* Format user for response (remove password_hash, add firstName/lastName)
*/
private formatUserResponse(user: InternalUser): AuthUser {
const { firstName, lastName } = splitFullName(user.full_name);
const { password_hash: _, ...userWithoutPassword } = user;
return {
...userWithoutPassword,
firstName,
lastName,
};
}
}
/**
* Factory function to create AuthService instance
*/
export function createAuthService(config: AuthServiceConfig): AuthService {
return new AuthService(config);
}