492 lines
13 KiB
TypeScript
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();
|