178 lines
5.2 KiB
JavaScript
178 lines
5.2 KiB
JavaScript
import crypto from 'node:crypto';
|
|
|
|
const TABLE = 'h5_admin_system_test_accounts';
|
|
|
|
function resolveEncryptionKey(explicitKey) {
|
|
const raw =
|
|
explicitKey ??
|
|
process.env.H5_SETTINGS_ENCRYPTION_KEY ??
|
|
process.env.TKMIND_SERVER__SECRET_KEY ??
|
|
'local-dev-secret';
|
|
return crypto.createHash('sha256').update(raw).digest();
|
|
}
|
|
|
|
function encryptSecret(plaintext, encryptionKey) {
|
|
const key = resolveEncryptionKey(encryptionKey);
|
|
const iv = crypto.randomBytes(12);
|
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
return {
|
|
ciphertext: encrypted.toString('base64'),
|
|
iv: iv.toString('base64'),
|
|
tag: cipher.getAuthTag().toString('base64'),
|
|
};
|
|
}
|
|
|
|
function decryptSecret({ ciphertext, iv, tag }, encryptionKey) {
|
|
const key = resolveEncryptionKey(encryptionKey);
|
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'));
|
|
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
|
const plain = Buffer.concat([
|
|
decipher.update(Buffer.from(ciphertext, 'base64')),
|
|
decipher.final(),
|
|
]);
|
|
return plain.toString('utf8');
|
|
}
|
|
|
|
function maskApiKey(apiKey) {
|
|
if (!apiKey) return '';
|
|
if (apiKey.length <= 8) return '*'.repeat(apiKey.length);
|
|
const head = apiKey.slice(0, 4);
|
|
const tail = apiKey.slice(-4);
|
|
return `${head}${'*'.repeat(Math.max(apiKey.length - 8, 4))}${tail}`;
|
|
}
|
|
|
|
function normalizeText(value) {
|
|
return String(value ?? '').trim();
|
|
}
|
|
|
|
function toAccountRow(row) {
|
|
return {
|
|
id: row.id,
|
|
label: row.label,
|
|
username: row.username,
|
|
passwordMasked: maskApiKey(row.password_preview ?? ''),
|
|
createdAt: Number(row.created_at ?? 0) || 0,
|
|
updatedAt: Number(row.updated_at ?? 0) || 0,
|
|
updatedBy: row.updated_by ?? null,
|
|
};
|
|
}
|
|
|
|
export async function ensureSystemTestAccountSchema(pool) {
|
|
await pool.query(`
|
|
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
id CHAR(36) NOT NULL PRIMARY KEY,
|
|
label VARCHAR(120) NOT NULL,
|
|
username VARCHAR(120) NOT NULL,
|
|
password_ciphertext TEXT NOT NULL,
|
|
password_iv VARCHAR(255) NOT NULL,
|
|
password_tag VARCHAR(255) NOT NULL,
|
|
password_preview VARCHAR(255) NOT NULL DEFAULT '',
|
|
updated_by CHAR(36) NULL,
|
|
created_at BIGINT NOT NULL,
|
|
updated_at BIGINT NOT NULL,
|
|
UNIQUE KEY uniq_${TABLE}_username (username)
|
|
)
|
|
`);
|
|
}
|
|
|
|
export function createSystemTestAccountService(pool) {
|
|
if (!pool) {
|
|
throw new Error('system test account service requires pool');
|
|
}
|
|
|
|
async function listAccounts() {
|
|
const [rows] = await pool.query(
|
|
`SELECT id, label, username, password_preview, updated_by, created_at, updated_at
|
|
FROM ${TABLE}
|
|
ORDER BY updated_at DESC, created_at DESC`,
|
|
);
|
|
return rows.map(toAccountRow);
|
|
}
|
|
|
|
async function getAccountSecret(accountId) {
|
|
const [rows] = await pool.query(
|
|
`SELECT id, label, username, password_ciphertext, password_iv, password_tag
|
|
FROM ${TABLE}
|
|
WHERE id = ?
|
|
LIMIT 1`,
|
|
[accountId],
|
|
);
|
|
const row = rows[0] ?? null;
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id,
|
|
label: row.label,
|
|
username: row.username,
|
|
password: decryptSecret({
|
|
ciphertext: row.password_ciphertext,
|
|
iv: row.password_iv,
|
|
tag: row.password_tag,
|
|
}),
|
|
};
|
|
}
|
|
|
|
async function createAccount({ label, username, password, updatedBy }) {
|
|
const normalizedUsername = normalizeText(username);
|
|
const normalizedLabel = normalizeText(label) || normalizedUsername;
|
|
if (!normalizedUsername || !password) {
|
|
return { ok: false, message: '账号和密码不能为空' };
|
|
}
|
|
const id = crypto.randomUUID();
|
|
const now = Date.now();
|
|
const encrypted = encryptSecret(String(password));
|
|
try {
|
|
await pool.query(
|
|
`INSERT INTO ${TABLE}
|
|
(id, label, username, password_ciphertext, password_iv, password_tag, password_preview, updated_by, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
id,
|
|
normalizedLabel,
|
|
normalizedUsername,
|
|
encrypted.ciphertext,
|
|
encrypted.iv,
|
|
encrypted.tag,
|
|
maskApiKey(String(password)),
|
|
updatedBy ?? null,
|
|
now,
|
|
now,
|
|
],
|
|
);
|
|
} catch (error) {
|
|
if (error?.code === 'ER_DUP_ENTRY') {
|
|
return { ok: false, message: '该测试账号已存在,请直接使用或先删除旧账号' };
|
|
}
|
|
throw error;
|
|
}
|
|
const account = await getAccountById(id);
|
|
return { ok: true, account };
|
|
}
|
|
|
|
async function getAccountById(accountId) {
|
|
const [rows] = await pool.query(
|
|
`SELECT id, label, username, password_preview, updated_by, created_at, updated_at
|
|
FROM ${TABLE}
|
|
WHERE id = ?
|
|
LIMIT 1`,
|
|
[accountId],
|
|
);
|
|
const row = rows[0] ?? null;
|
|
return row ? toAccountRow(row) : null;
|
|
}
|
|
|
|
async function deleteAccount(accountId) {
|
|
const existing = await getAccountById(accountId);
|
|
if (!existing) return { ok: false, message: '测试账号不存在' };
|
|
await pool.query(`DELETE FROM ${TABLE} WHERE id = ? LIMIT 1`, [accountId]);
|
|
return { ok: true };
|
|
}
|
|
|
|
return {
|
|
listAccounts,
|
|
getAccountSecret,
|
|
createAccount,
|
|
deleteAccount,
|
|
};
|
|
}
|