420 lines
10 KiB
TypeScript
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);
|
|
}
|