493 lines
15 KiB
JavaScript
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,
|
|
},
|
|
};
|