cangku/backend/src/services/selectionFissionApiKeyService.js

493 lines
15 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const net = require('net');
const database = require('../config/database');
const API_KEY_TABLE = 'external_api_keys';
const API_KEY_META_TABLE = 'external_api_key_meta';
const SELECTION_FISSION_SCOPE = 'selection_fission';
const LEGACY_MIGRATION_KEY = 'selection_fission_env_keys_migrated_v1';
const MIN_API_KEY_LENGTH = 16;
const MAX_API_KEY_LENGTH = 500;
let schemaReady = false;
let schemaPromise = null;
function normalizeText(value, maxLength = 255) {
return String(value ?? '').trim().slice(0, maxLength);
}
function getLegacyEnvironmentKeys() {
return [
process.env.SELECTION_FISSION_PUBLIC_API_KEY,
process.env.SELECTION_FISSION_PUBLIC_API_KEYS,
process.env.EXTERNAL_SELECTION_FISSION_API_KEY,
process.env.EXTERNAL_SELECTION_FISSION_API_KEYS,
]
.filter(Boolean)
.flatMap((value) => String(value).split(','))
.map((value) => value.trim())
.filter(Boolean);
}
function hashApiKey(apiKey) {
return crypto.createHash('sha256').update(String(apiKey || ''), 'utf8').digest('hex');
}
function generateApiKey() {
return `sf_live_${crypto.randomBytes(32).toString('base64url')}`;
}
function buildKeyPreview(apiKey) {
const normalized = normalizeText(apiKey, MAX_API_KEY_LENGTH);
return {
keyPrefix: normalized.slice(0, 10),
keySuffix: normalized.slice(-4),
};
}
function maskApiKey(keyPrefix, keySuffix) {
const prefix = normalizeText(keyPrefix, 20);
const suffix = normalizeText(keySuffix, 8);
return prefix && suffix ? `${prefix}••••••••${suffix}` : '••••••••••••';
}
function normalizeApiKeyInput(value, { required = false } = {}) {
const apiKey = normalizeText(value, MAX_API_KEY_LENGTH);
if (!apiKey && !required) return '';
if (!apiKey) {
const error = new Error('API Key 不能为空');
error.status = 400;
throw error;
}
if (apiKey.length < MIN_API_KEY_LENGTH) {
const error = new Error(`API Key 长度不能少于 ${MIN_API_KEY_LENGTH} 位`);
error.status = 400;
throw error;
}
return apiKey;
}
function normalizeExpiresAt(value) {
if (value === undefined) return undefined;
if (value === null || value === '') return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
const error = new Error('过期时间格式不正确');
error.status = 400;
throw error;
}
return date;
}
function getActor(user = {}) {
return {
id: normalizeText(user.sub || user.id || user.userId, 120) || null,
name: normalizeText(user.name || user.username || user.userName || user.email, 120) || null,
};
}
function normalizeAllowedIps(value) {
const entries = Array.isArray(value)
? value
: String(value || '').split(/[\s,;]+/);
const normalized = entries
.map((entry) => normalizeText(entry, 80).replace(/^::ffff:/i, ''))
.filter(Boolean);
for (const ip of normalized) {
if (!net.isIP(ip)) {
const error = new Error(`IP 白名单包含无效地址:${ip}`);
error.status = 400;
throw error;
}
}
return Array.from(new Set(normalized));
}
function mapApiKeyRow(row = {}) {
const expiresAt = row.expires_at || null;
const allowedIps = normalizeAllowedIps(row.allowed_ips || '');
return {
id: Number(row.id),
name: row.name || '',
serviceScope: row.service_scope || SELECTION_FISSION_SCOPE,
serviceName: '自动上架与预售对外 API',
keyMasked: maskApiKey(row.key_prefix, row.key_suffix),
keyPrefix: row.key_prefix || '',
status: row.status || 'disabled',
enabled: row.status === 'active',
description: row.description || '',
allowedIps,
hmacRequired: Number(row.hmac_required || 0) === 1,
expiresAt,
expired: Boolean(expiresAt && new Date(expiresAt).getTime() <= Date.now()),
lastUsedAt: row.last_used_at || null,
createdBy: row.created_by_name || row.created_by_user_id || '',
updatedBy: row.updated_by_name || row.updated_by_user_id || '',
createdAt: row.created_at || null,
updatedAt: row.updated_at || null,
};
}
async function migrateLegacyEnvironmentKeys() {
const markers = await database.local.query(
`SELECT setting_value
FROM ${API_KEY_META_TABLE}
WHERE setting_key = ?
LIMIT 1`,
[LEGACY_MIGRATION_KEY]
);
if (markers[0]) return;
const legacyKeys = Array.from(new Set(getLegacyEnvironmentKeys()));
for (let index = 0; index < legacyKeys.length; index += 1) {
const apiKey = normalizeText(legacyKeys[index], MAX_API_KEY_LENGTH);
if (!apiKey) continue;
const preview = buildKeyPreview(apiKey);
await database.local.query(
`INSERT IGNORE INTO ${API_KEY_TABLE}
(
service_scope, name, key_hash, key_prefix, key_suffix, status,
description, created_by_name, updated_by_name
)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?)`,
[
SELECTION_FISSION_SCOPE,
legacyKeys.length > 1 ? `原统一 Key ${index + 1}` : '原统一 Key',
hashApiKey(apiKey),
preview.keyPrefix,
preview.keySuffix,
'由服务器环境变量自动迁移',
'系统迁移',
'系统迁移',
]
);
}
await database.local.query(
`INSERT INTO ${API_KEY_META_TABLE} (setting_key, setting_value)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = CURRENT_TIMESTAMP`,
[LEGACY_MIGRATION_KEY, new Date().toISOString()]
);
}
async function ensureApiKeySchema() {
if (schemaReady) return;
if (schemaPromise) {
await schemaPromise;
return;
}
schemaPromise = (async () => {
await database.local.query(
`CREATE TABLE IF NOT EXISTS ${API_KEY_TABLE} (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
service_scope VARCHAR(80) NOT NULL DEFAULT 'selection_fission' COMMENT 'API用途',
name VARCHAR(120) NOT NULL COMMENT 'Key名称',
key_hash CHAR(64) NOT NULL COMMENT 'SHA-256摘要',
key_prefix VARCHAR(20) NOT NULL COMMENT '掩码前缀',
key_suffix VARCHAR(8) NOT NULL COMMENT '掩码后缀',
status ENUM('active', 'disabled') NOT NULL DEFAULT 'active' COMMENT '状态',
description VARCHAR(500) NULL COMMENT '备注',
expires_at DATETIME NULL COMMENT '过期时间',
last_used_at DATETIME NULL COMMENT '最近使用时间',
created_by_user_id VARCHAR(120) NULL COMMENT '创建人ID',
created_by_name VARCHAR(120) NULL COMMENT '创建人',
updated_by_user_id VARCHAR(120) NULL COMMENT '更新人ID',
updated_by_name VARCHAR(120) NULL COMMENT '更新人',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_external_api_key_hash (key_hash),
KEY idx_external_api_key_scope_status (service_scope, status),
KEY idx_external_api_key_updated (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方 API Key'`
);
const apiKeyColumns = await database.local.query(`SHOW COLUMNS FROM ${API_KEY_TABLE}`);
const existingColumns = new Set(apiKeyColumns.map((column) => column.Field));
if (!existingColumns.has('allowed_ips')) {
await database.local.query(
`ALTER TABLE ${API_KEY_TABLE}
ADD COLUMN allowed_ips VARCHAR(2000) NULL COMMENT '允许访问的IP白名单' AFTER description`
);
}
if (!existingColumns.has('hmac_required')) {
await database.local.query(
`ALTER TABLE ${API_KEY_TABLE}
ADD COLUMN hmac_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否强制HMAC签名' AFTER allowed_ips`
);
}
await database.local.query(
`CREATE TABLE IF NOT EXISTS ${API_KEY_META_TABLE} (
setting_key VARCHAR(120) PRIMARY KEY COMMENT '配置键',
setting_value VARCHAR(500) NULL COMMENT '配置值',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方 API Key 元数据'`
);
await migrateLegacyEnvironmentKeys();
schemaReady = true;
})();
try {
await schemaPromise;
} finally {
schemaPromise = null;
}
}
async function getApiKeyById(id) {
const normalizedId = Number.parseInt(id, 10);
if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
const error = new Error('API Key ID 不正确');
error.status = 400;
throw error;
}
const rows = await database.local.query(
`SELECT *
FROM ${API_KEY_TABLE}
WHERE id = ? AND service_scope = ?
LIMIT 1`,
[normalizedId, SELECTION_FISSION_SCOPE]
);
if (!rows[0]) {
const error = new Error('API Key 不存在');
error.status = 404;
throw error;
}
return rows[0];
}
async function listSelectionFissionApiKeys() {
await ensureApiKeySchema();
const rows = await database.local.query(
`SELECT *
FROM ${API_KEY_TABLE}
WHERE service_scope = ?
ORDER BY status = 'active' DESC, updated_at DESC, id DESC`,
[SELECTION_FISSION_SCOPE]
);
return rows.map(mapApiKeyRow);
}
async function createSelectionFissionApiKey(input = {}, user = {}) {
await ensureApiKeySchema();
const name = normalizeText(input.name, 120);
if (!name) {
const error = new Error('请输入 API Key 名称');
error.status = 400;
throw error;
}
const plainApiKey = input.apiKey
? normalizeApiKeyInput(input.apiKey, { required: true })
: generateApiKey();
const preview = buildKeyPreview(plainApiKey);
const actor = getActor(user);
const status = input.enabled === false || input.status === 'disabled' ? 'disabled' : 'active';
const expiresAt = normalizeExpiresAt(input.expiresAt);
try {
const result = await database.local.insert(
`INSERT INTO ${API_KEY_TABLE}
(
service_scope, name, key_hash, key_prefix, key_suffix, status,
description, allowed_ips, hmac_required, expires_at, created_by_user_id, created_by_name,
updated_by_user_id, updated_by_name
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
SELECTION_FISSION_SCOPE,
name,
hashApiKey(plainApiKey),
preview.keyPrefix,
preview.keySuffix,
status,
normalizeText(input.description, 500) || null,
normalizeAllowedIps(input.allowedIps || input.allowed_ips).join(',') || null,
input.hmacRequired === true || input.hmac_required === true ? 1 : 0,
expiresAt ?? null,
actor.id,
actor.name,
actor.id,
actor.name,
]
);
const row = await getApiKeyById(result.insertId);
return {
...mapApiKeyRow(row),
plainApiKey,
};
} catch (error) {
if (error?.code === 'ER_DUP_ENTRY') {
const conflict = new Error('该 API Key 已存在');
conflict.status = 409;
throw conflict;
}
throw error;
}
}
async function updateSelectionFissionApiKey(id, input = {}, user = {}) {
await ensureApiKeySchema();
const existing = await getApiKeyById(id);
const actor = getActor(user);
const updates = [];
const params = [];
let plainApiKey = null;
if (Object.prototype.hasOwnProperty.call(input, 'name')) {
const name = normalizeText(input.name, 120);
if (!name) {
const error = new Error('请输入 API Key 名称');
error.status = 400;
throw error;
}
updates.push('name = ?');
params.push(name);
}
if (Object.prototype.hasOwnProperty.call(input, 'description')) {
updates.push('description = ?');
params.push(normalizeText(input.description, 500) || null);
}
if (
Object.prototype.hasOwnProperty.call(input, 'allowedIps')
|| Object.prototype.hasOwnProperty.call(input, 'allowed_ips')
) {
updates.push('allowed_ips = ?');
params.push(normalizeAllowedIps(input.allowedIps || input.allowed_ips).join(',') || null);
}
if (
Object.prototype.hasOwnProperty.call(input, 'hmacRequired')
|| Object.prototype.hasOwnProperty.call(input, 'hmac_required')
) {
updates.push('hmac_required = ?');
params.push(input.hmacRequired === true || input.hmac_required === true ? 1 : 0);
}
if (
Object.prototype.hasOwnProperty.call(input, 'enabled')
|| Object.prototype.hasOwnProperty.call(input, 'status')
) {
const status = input.enabled === false || input.status === 'disabled' ? 'disabled' : 'active';
updates.push('status = ?');
params.push(status);
}
if (Object.prototype.hasOwnProperty.call(input, 'expiresAt')) {
updates.push('expires_at = ?');
params.push(normalizeExpiresAt(input.expiresAt) ?? null);
}
if (normalizeText(input.apiKey, MAX_API_KEY_LENGTH)) {
plainApiKey = normalizeApiKeyInput(input.apiKey, { required: true });
const preview = buildKeyPreview(plainApiKey);
updates.push('key_hash = ?', 'key_prefix = ?', 'key_suffix = ?');
params.push(hashApiKey(plainApiKey), preview.keyPrefix, preview.keySuffix);
}
updates.push('updated_by_user_id = ?', 'updated_by_name = ?');
params.push(actor.id, actor.name);
try {
await database.local.query(
`UPDATE ${API_KEY_TABLE}
SET ${updates.join(', ')}
WHERE id = ? AND service_scope = ?`,
[...params, existing.id, SELECTION_FISSION_SCOPE]
);
} catch (error) {
if (error?.code === 'ER_DUP_ENTRY') {
const conflict = new Error('该 API Key 已存在');
conflict.status = 409;
throw conflict;
}
throw error;
}
const row = await getApiKeyById(existing.id);
return {
...mapApiKeyRow(row),
plainApiKey,
};
}
async function deleteSelectionFissionApiKey(id) {
await ensureApiKeySchema();
const existing = await getApiKeyById(id);
await database.local.query(
`DELETE FROM ${API_KEY_TABLE}
WHERE id = ? AND service_scope = ?`,
[existing.id, SELECTION_FISSION_SCOPE]
);
return mapApiKeyRow(existing);
}
async function authenticateSelectionFissionApiKey(apiKey) {
await ensureApiKeySchema();
const activeCountRows = await database.local.query(
`SELECT COUNT(*) AS total
FROM ${API_KEY_TABLE}
WHERE service_scope = ?
AND status = 'active'
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)`,
[SELECTION_FISSION_SCOPE]
);
const configured = Number(activeCountRows[0]?.total || 0) > 0;
const normalizedApiKey = normalizeText(apiKey, MAX_API_KEY_LENGTH);
if (!normalizedApiKey) {
return { configured, valid: false };
}
const rows = await database.local.query(
`SELECT id, name, allowed_ips, hmac_required
FROM ${API_KEY_TABLE}
WHERE service_scope = ?
AND key_hash = ?
AND status = 'active'
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
LIMIT 1`,
[SELECTION_FISSION_SCOPE, hashApiKey(normalizedApiKey)]
);
if (!rows[0]) {
return { configured, valid: false };
}
await database.local.query(
`UPDATE ${API_KEY_TABLE}
SET last_used_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[rows[0].id]
);
return {
configured: true,
valid: true,
keyId: Number(rows[0].id),
keyName: rows[0].name || '',
allowedIps: normalizeAllowedIps(rows[0].allowed_ips || ''),
hmacRequired: Number(rows[0].hmac_required || 0) === 1,
};
}
module.exports = {
authenticateSelectionFissionApiKey,
createSelectionFissionApiKey,
deleteSelectionFissionApiKey,
ensureApiKeySchema,
listSelectionFissionApiKeys,
updateSelectionFissionApiKey,
_test: {
buildKeyPreview,
generateApiKey,
hashApiKey,
maskApiKey,
normalizeApiKeyInput,
normalizeAllowedIps,
},
};