erp-core/backend/src/modules/auth/apiKeys.service.ts

492 lines
13 KiB
TypeScript

import crypto from 'crypto';
import { query, queryOne } from '../../config/database.js';
import { ValidationError, NotFoundError, UnauthorizedError } from '../../shared/types/index.js';
import { logger } from '../../shared/utils/logger.js';
// ============================================================================
// TYPES
// ============================================================================
export interface ApiKey {
id: string;
user_id: string;
tenant_id: string;
name: string;
key_index: string;
key_hash: string;
scope: string | null;
allowed_ips: string[] | null;
expiration_date: Date | null;
last_used_at: Date | null;
is_active: boolean;
created_at: Date;
updated_at: Date;
}
export interface CreateApiKeyDto {
user_id: string;
tenant_id: string;
name: string;
scope?: string;
allowed_ips?: string[];
expiration_days?: number;
}
export interface UpdateApiKeyDto {
name?: string;
scope?: string;
allowed_ips?: string[];
expiration_date?: Date | null;
is_active?: boolean;
}
export interface ApiKeyWithPlainKey {
apiKey: Omit<ApiKey, 'key_hash'>;
plainKey: string;
}
export interface ApiKeyValidationResult {
valid: boolean;
apiKey?: ApiKey;
user?: {
id: string;
tenant_id: string;
email: string;
roles: string[];
};
error?: string;
}
export interface ApiKeyFilters {
user_id?: string;
tenant_id?: string;
is_active?: boolean;
scope?: string;
}
// ============================================================================
// CONSTANTS
// ============================================================================
const API_KEY_PREFIX = 'mgn_';
const KEY_LENGTH = 32; // 32 bytes = 256 bits
const HASH_ITERATIONS = 100000;
const HASH_KEYLEN = 64;
const HASH_DIGEST = 'sha512';
// ============================================================================
// SERVICE
// ============================================================================
class ApiKeysService {
/**
* Generate a cryptographically secure API key
*/
private generatePlainKey(): string {
const randomBytes = crypto.randomBytes(KEY_LENGTH);
const key = randomBytes.toString('base64url');
return `${API_KEY_PREFIX}${key}`;
}
/**
* Extract the key index (first 16 chars after prefix) for lookup
*/
private getKeyIndex(plainKey: string): string {
const keyWithoutPrefix = plainKey.replace(API_KEY_PREFIX, '');
return keyWithoutPrefix.substring(0, 16);
}
/**
* Hash the API key using PBKDF2
*/
private async hashKey(plainKey: string): Promise<string> {
const salt = crypto.randomBytes(16).toString('hex');
return new Promise((resolve, reject) => {
crypto.pbkdf2(
plainKey,
salt,
HASH_ITERATIONS,
HASH_KEYLEN,
HASH_DIGEST,
(err, derivedKey) => {
if (err) reject(err);
resolve(`${salt}:${derivedKey.toString('hex')}`);
}
);
});
}
/**
* Verify a plain key against a stored hash
*/
private async verifyKey(plainKey: string, storedHash: string): Promise<boolean> {
const [salt, hash] = storedHash.split(':');
return new Promise((resolve, reject) => {
crypto.pbkdf2(
plainKey,
salt,
HASH_ITERATIONS,
HASH_KEYLEN,
HASH_DIGEST,
(err, derivedKey) => {
if (err) reject(err);
resolve(derivedKey.toString('hex') === hash);
}
);
});
}
/**
* Create a new API key
* Returns the plain key only once - it cannot be retrieved later
*/
async create(dto: CreateApiKeyDto): Promise<ApiKeyWithPlainKey> {
// Validate user exists
const user = await queryOne<{ id: string }>(
'SELECT id FROM auth.users WHERE id = $1 AND tenant_id = $2',
[dto.user_id, dto.tenant_id]
);
if (!user) {
throw new ValidationError('Usuario no encontrado');
}
// Check for duplicate name
const existing = await queryOne<{ id: string }>(
'SELECT id FROM auth.api_keys WHERE user_id = $1 AND name = $2',
[dto.user_id, dto.name]
);
if (existing) {
throw new ValidationError('Ya existe una API key con ese nombre');
}
// Generate key
const plainKey = this.generatePlainKey();
const keyIndex = this.getKeyIndex(plainKey);
const keyHash = await this.hashKey(plainKey);
// Calculate expiration date
let expirationDate: Date | null = null;
if (dto.expiration_days) {
expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + dto.expiration_days);
}
// Insert API key
const apiKey = await queryOne<ApiKey>(
`INSERT INTO auth.api_keys (
user_id, tenant_id, name, key_index, key_hash,
scope, allowed_ips, expiration_date, is_active
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true)
RETURNING id, user_id, tenant_id, name, key_index, scope,
allowed_ips, expiration_date, is_active, created_at, updated_at`,
[
dto.user_id,
dto.tenant_id,
dto.name,
keyIndex,
keyHash,
dto.scope || null,
dto.allowed_ips || null,
expirationDate,
]
);
if (!apiKey) {
throw new Error('Error al crear API key');
}
logger.info('API key created', {
apiKeyId: apiKey.id,
userId: dto.user_id,
name: dto.name
});
return {
apiKey,
plainKey, // Only returned once!
};
}
/**
* Find all API keys for a user/tenant
*/
async findAll(filters: ApiKeyFilters): Promise<Omit<ApiKey, 'key_hash'>[]> {
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (filters.user_id) {
conditions.push(`user_id = $${paramIndex++}`);
params.push(filters.user_id);
}
if (filters.tenant_id) {
conditions.push(`tenant_id = $${paramIndex++}`);
params.push(filters.tenant_id);
}
if (filters.is_active !== undefined) {
conditions.push(`is_active = $${paramIndex++}`);
params.push(filters.is_active);
}
if (filters.scope) {
conditions.push(`scope = $${paramIndex++}`);
params.push(filters.scope);
}
const whereClause = conditions.length > 0
? `WHERE ${conditions.join(' AND ')}`
: '';
const apiKeys = await query<ApiKey>(
`SELECT id, user_id, tenant_id, name, key_index, scope,
allowed_ips, expiration_date, last_used_at, is_active,
created_at, updated_at
FROM auth.api_keys
${whereClause}
ORDER BY created_at DESC`,
params
);
return apiKeys;
}
/**
* Find a specific API key by ID
*/
async findById(id: string, tenantId: string): Promise<Omit<ApiKey, 'key_hash'> | null> {
const apiKey = await queryOne<ApiKey>(
`SELECT id, user_id, tenant_id, name, key_index, scope,
allowed_ips, expiration_date, last_used_at, is_active,
created_at, updated_at
FROM auth.api_keys
WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
return apiKey;
}
/**
* Update an API key
*/
async update(id: string, tenantId: string, dto: UpdateApiKeyDto): Promise<Omit<ApiKey, 'key_hash'>> {
const existing = await this.findById(id, tenantId);
if (!existing) {
throw new NotFoundError('API key no encontrada');
}
const updates: string[] = ['updated_at = NOW()'];
const params: any[] = [];
let paramIndex = 1;
if (dto.name !== undefined) {
updates.push(`name = $${paramIndex++}`);
params.push(dto.name);
}
if (dto.scope !== undefined) {
updates.push(`scope = $${paramIndex++}`);
params.push(dto.scope);
}
if (dto.allowed_ips !== undefined) {
updates.push(`allowed_ips = $${paramIndex++}`);
params.push(dto.allowed_ips);
}
if (dto.expiration_date !== undefined) {
updates.push(`expiration_date = $${paramIndex++}`);
params.push(dto.expiration_date);
}
if (dto.is_active !== undefined) {
updates.push(`is_active = $${paramIndex++}`);
params.push(dto.is_active);
}
params.push(id);
params.push(tenantId);
const updated = await queryOne<ApiKey>(
`UPDATE auth.api_keys
SET ${updates.join(', ')}
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}
RETURNING id, user_id, tenant_id, name, key_index, scope,
allowed_ips, expiration_date, last_used_at, is_active,
created_at, updated_at`,
params
);
if (!updated) {
throw new Error('Error al actualizar API key');
}
logger.info('API key updated', { apiKeyId: id });
return updated;
}
/**
* Revoke (soft delete) an API key
*/
async revoke(id: string, tenantId: string): Promise<void> {
const result = await query(
`UPDATE auth.api_keys
SET is_active = false, updated_at = NOW()
WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
if (!result) {
throw new NotFoundError('API key no encontrada');
}
logger.info('API key revoked', { apiKeyId: id });
}
/**
* Delete an API key permanently
*/
async delete(id: string, tenantId: string): Promise<void> {
const result = await query(
'DELETE FROM auth.api_keys WHERE id = $1 AND tenant_id = $2',
[id, tenantId]
);
logger.info('API key deleted', { apiKeyId: id });
}
/**
* Validate an API key and return the associated user info
* This is the main method used by the authentication middleware
*/
async validate(plainKey: string, clientIp?: string): Promise<ApiKeyValidationResult> {
// Check prefix
if (!plainKey.startsWith(API_KEY_PREFIX)) {
return { valid: false, error: 'Formato de API key inválido' };
}
// Extract key index for lookup
const keyIndex = this.getKeyIndex(plainKey);
// Find API key by index
const apiKey = await queryOne<ApiKey>(
`SELECT * FROM auth.api_keys
WHERE key_index = $1 AND is_active = true`,
[keyIndex]
);
if (!apiKey) {
return { valid: false, error: 'API key no encontrada o inactiva' };
}
// Verify hash
const isValid = await this.verifyKey(plainKey, apiKey.key_hash);
if (!isValid) {
return { valid: false, error: 'API key inválida' };
}
// Check expiration
if (apiKey.expiration_date && new Date(apiKey.expiration_date) < new Date()) {
return { valid: false, error: 'API key expirada' };
}
// Check IP whitelist
if (apiKey.allowed_ips && apiKey.allowed_ips.length > 0 && clientIp) {
if (!apiKey.allowed_ips.includes(clientIp)) {
logger.warn('API key IP not allowed', {
apiKeyId: apiKey.id,
clientIp,
allowedIps: apiKey.allowed_ips
});
return { valid: false, error: 'IP no autorizada' };
}
}
// Get user info with roles
const user = await queryOne<{
id: string;
tenant_id: string;
email: string;
role_codes: string[];
}>(
`SELECT u.id, u.tenant_id, u.email, 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 AND u.status = 'active'
GROUP BY u.id`,
[apiKey.user_id]
);
if (!user) {
return { valid: false, error: 'Usuario asociado no encontrado o inactivo' };
}
// Update last used timestamp (async, don't wait)
query(
'UPDATE auth.api_keys SET last_used_at = NOW() WHERE id = $1',
[apiKey.id]
).catch(err => logger.error('Error updating last_used_at', { error: err }));
return {
valid: true,
apiKey,
user: {
id: user.id,
tenant_id: user.tenant_id,
email: user.email,
roles: user.role_codes?.filter(Boolean) || [],
},
};
}
/**
* Regenerate an API key (creates new key, invalidates old)
*/
async regenerate(id: string, tenantId: string): Promise<ApiKeyWithPlainKey> {
const existing = await queryOne<ApiKey>(
'SELECT * FROM auth.api_keys WHERE id = $1 AND tenant_id = $2',
[id, tenantId]
);
if (!existing) {
throw new NotFoundError('API key no encontrada');
}
// Generate new key
const plainKey = this.generatePlainKey();
const keyIndex = this.getKeyIndex(plainKey);
const keyHash = await this.hashKey(plainKey);
// Update with new key
const updated = await queryOne<ApiKey>(
`UPDATE auth.api_keys
SET key_index = $1, key_hash = $2, updated_at = NOW()
WHERE id = $3 AND tenant_id = $4
RETURNING id, user_id, tenant_id, name, key_index, scope,
allowed_ips, expiration_date, is_active, created_at, updated_at`,
[keyIndex, keyHash, id, tenantId]
);
if (!updated) {
throw new Error('Error al regenerar API key');
}
logger.info('API key regenerated', { apiKeyId: id });
return {
apiKey: updated,
plainKey,
};
}
}
export const apiKeysService = new ApiKeysService();