268 lines
9.8 KiB
JavaScript
268 lines
9.8 KiB
JavaScript
import crypto from 'node:crypto';
|
|
|
|
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30;
|
|
|
|
function now() {
|
|
return new Date();
|
|
}
|
|
|
|
function hashPassword(password, salt = crypto.randomBytes(16).toString('hex')) {
|
|
const digest = crypto.scryptSync(String(password), salt, 64).toString('hex');
|
|
return `scrypt$${salt}$${digest}`;
|
|
}
|
|
|
|
function verifyPassword(password, stored) {
|
|
const [scheme, salt, digest] = String(stored ?? '').split('$');
|
|
if (scheme !== 'scrypt' || !salt || !digest) return false;
|
|
const next = crypto.scryptSync(String(password), salt, 64).toString('hex');
|
|
return crypto.timingSafeEqual(Buffer.from(next, 'hex'), Buffer.from(digest, 'hex'));
|
|
}
|
|
|
|
function rowToUser(row) {
|
|
if (!row) return null;
|
|
return {
|
|
id: String(row.id),
|
|
username: row.username,
|
|
displayName: row.display_name ?? row.username,
|
|
role: row.role,
|
|
status: row.status,
|
|
balanceCents: Number(row.balance_cents ?? 0),
|
|
workspaceRoot: row.workspace_root ?? '',
|
|
createdAt: row.created_at ? new Date(row.created_at).getTime() : Date.now(),
|
|
updatedAt: row.updated_at ? new Date(row.updated_at).getTime() : Date.now(),
|
|
};
|
|
}
|
|
|
|
function defaultCapabilities() {
|
|
return {};
|
|
}
|
|
|
|
export function parseCookies(cookieHeader = '') {
|
|
return Object.fromEntries(
|
|
cookieHeader.split(';').map((part) => {
|
|
const index = part.indexOf('=');
|
|
if (index < 0) return ['', ''];
|
|
return [decodeURIComponent(part.slice(0, index).trim()), decodeURIComponent(part.slice(index + 1).trim())];
|
|
}).filter(([k]) => k),
|
|
);
|
|
}
|
|
|
|
export const USER_COOKIE = 'tkmind_admin_token';
|
|
|
|
export function userLoginCookies(token, secure, domain) {
|
|
const parts = [
|
|
`${USER_COOKIE}=${encodeURIComponent(token)}`,
|
|
'Path=/',
|
|
'HttpOnly',
|
|
'SameSite=Lax',
|
|
secure ? 'Secure' : null,
|
|
domain ? `Domain=${domain}` : null,
|
|
`Max-Age=${SESSION_TTL_MS / 1000}`,
|
|
].filter(Boolean);
|
|
return `${parts.join('; ')}`;
|
|
}
|
|
|
|
export function clearUserSessionCookie(secure, domain) {
|
|
const parts = [
|
|
`${USER_COOKIE}=`,
|
|
'Path=/',
|
|
'HttpOnly',
|
|
'SameSite=Lax',
|
|
secure ? 'Secure' : null,
|
|
domain ? `Domain=${domain}` : null,
|
|
'Max-Age=0',
|
|
].filter(Boolean);
|
|
return `${parts.join('; ')}`;
|
|
}
|
|
|
|
export function resolveCookieDomainForRequest(_req) {
|
|
return '';
|
|
}
|
|
|
|
export function createLocalUserAuth(pool) {
|
|
const capabilityCatalog = [];
|
|
const policyCatalog = [];
|
|
const skillCatalog = [];
|
|
|
|
async function ensureTables() {
|
|
await pool.execute(`
|
|
CREATE TABLE IF NOT EXISTS auth_users (
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
username VARCHAR(191) NOT NULL UNIQUE,
|
|
display_name VARCHAR(191) NULL,
|
|
role VARCHAR(32) NOT NULL DEFAULT 'user',
|
|
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
|
password_hash VARCHAR(255) NOT NULL,
|
|
balance_cents BIGINT NOT NULL DEFAULT 0,
|
|
workspace_root VARCHAR(255) NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
await pool.execute(`
|
|
CREATE TABLE IF NOT EXISTS auth_sessions (
|
|
token VARCHAR(191) NOT NULL PRIMARY KEY,
|
|
user_id BIGINT UNSIGNED NOT NULL,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
INDEX idx_auth_sessions_user_id (user_id),
|
|
INDEX idx_auth_sessions_expires_at (expires_at)
|
|
)
|
|
`);
|
|
}
|
|
|
|
async function ensureAdminUser() {
|
|
await ensureTables();
|
|
const [rows] = await pool.execute('SELECT id FROM auth_users WHERE username = ? LIMIT 1', ['admin']);
|
|
if (rows.length) return;
|
|
throw new Error('admin 账号未初始化,请先运行 npm run admin:init');
|
|
}
|
|
|
|
async function getUserByUsername(username) {
|
|
const [rows] = await pool.execute('SELECT * FROM auth_users WHERE username = ? LIMIT 1', [username]);
|
|
return rowToUser(rows[0]);
|
|
}
|
|
|
|
async function getUserById(id) {
|
|
const [rows] = await pool.execute('SELECT * FROM auth_users WHERE id = ? LIMIT 1', [id]);
|
|
return rowToUser(rows[0]);
|
|
}
|
|
|
|
async function getMe(token) {
|
|
if (!token) return null;
|
|
const [rows] = await pool.execute(
|
|
'SELECT u.* FROM auth_sessions s JOIN auth_users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > NOW() LIMIT 1',
|
|
[token],
|
|
);
|
|
return rowToUser(rows[0]);
|
|
}
|
|
|
|
async function revoke(token) {
|
|
if (!token) return;
|
|
await pool.execute('DELETE FROM auth_sessions WHERE token = ?', [token]);
|
|
}
|
|
|
|
async function login({ username, password }) {
|
|
await ensureTables();
|
|
const [rows] = await pool.execute('SELECT * FROM auth_users WHERE username = ? LIMIT 1', [username]);
|
|
const row = rows[0];
|
|
if (!row) return { ok: false, message: '账号或密码错误', retryAfterMs: 0 };
|
|
if (!verifyPassword(password, row.password_hash)) return { ok: false, message: '账号或密码错误', retryAfterMs: 0 };
|
|
const token = crypto.randomUUID().replace(/-/g, '');
|
|
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
|
await pool.execute('INSERT INTO auth_sessions (token, user_id, expires_at) VALUES (?, ?, ?)', [token, row.id, expiresAt]);
|
|
return { ok: true, token, user: rowToUser(row) };
|
|
}
|
|
|
|
async function listUsers({ page = 1, pageSize = 20, search = '', role = '', status = '' }) {
|
|
await ensureTables();
|
|
const clauses = [];
|
|
const params = [];
|
|
if (search) {
|
|
clauses.push('(username LIKE ? OR display_name LIKE ?)');
|
|
params.push(`%${search}%`, `%${search}%`);
|
|
}
|
|
if (role) {
|
|
clauses.push('role = ?');
|
|
params.push(role);
|
|
}
|
|
if (status) {
|
|
clauses.push('status = ?');
|
|
params.push(status);
|
|
}
|
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
const [[countRow]] = await pool.execute(`SELECT COUNT(*) AS total FROM auth_users ${where}`, params);
|
|
const offset = (Math.max(page, 1) - 1) * Math.max(pageSize, 1);
|
|
const [rows] = await pool.execute(
|
|
`SELECT * FROM auth_users ${where} ORDER BY id DESC LIMIT ? OFFSET ?`,
|
|
[...params, Math.max(pageSize, 1), offset],
|
|
);
|
|
return {
|
|
items: rows.map((row) => rowToUser(row)),
|
|
total: Number(countRow.total ?? 0),
|
|
page,
|
|
pageSize,
|
|
totalPages: Math.max(1, Math.ceil(Number(countRow.total ?? 0) / pageSize)),
|
|
};
|
|
}
|
|
|
|
async function createUser(payload) {
|
|
if (!payload?.username || !payload?.password) return { ok: false, message: '用户名和密码不能为空' };
|
|
const [exists] = await pool.execute('SELECT id FROM auth_users WHERE username = ? LIMIT 1', [payload.username]);
|
|
if (exists.length) return { ok: false, message: '用户名已存在' };
|
|
await pool.execute(
|
|
'INSERT INTO auth_users (username, display_name, role, status, password_hash, balance_cents, workspace_root) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
[
|
|
payload.username,
|
|
payload.displayName || payload.username,
|
|
payload.role === 'admin' ? 'admin' : 'user',
|
|
'active',
|
|
hashPassword(payload.password),
|
|
Number(payload.balanceCents ?? 0),
|
|
payload.workspaceRoot ?? null,
|
|
],
|
|
);
|
|
return { ok: true, user: await getUserByUsername(payload.username) };
|
|
}
|
|
|
|
async function updateUser(userId, payload) {
|
|
const user = await getUserById(userId);
|
|
if (!user) return { ok: false, message: '用户不存在' };
|
|
const displayName = payload.displayName ?? user.displayName;
|
|
const workspaceRoot = payload.workspaceRoot ?? user.workspaceRoot;
|
|
const status = payload.status ?? user.status;
|
|
const role = payload.role === 'admin' ? 'admin' : user.role;
|
|
const balanceCents = payload.balanceCents ?? user.balanceCents;
|
|
await pool.execute(
|
|
'UPDATE auth_users SET display_name = ?, workspace_root = ?, status = ?, role = ?, balance_cents = ? WHERE id = ?',
|
|
[displayName, workspaceRoot || null, status, role, balanceCents, userId],
|
|
);
|
|
return { ok: true, user: await getUserById(userId) };
|
|
}
|
|
|
|
async function recharge(userId, amountCents) {
|
|
const user = await getUserById(userId);
|
|
if (!user) return { ok: false, message: '用户不存在' };
|
|
await pool.execute('UPDATE auth_users SET balance_cents = balance_cents + ? WHERE id = ?', [Number(amountCents ?? 0), userId]);
|
|
return { ok: true, user: await getUserById(userId) };
|
|
}
|
|
|
|
async function getAdminSummary() {
|
|
const [[row]] = await pool.execute(
|
|
`SELECT COUNT(*) AS total, SUM(CASE WHEN balance_cents < 0 THEN 1 ELSE 0 END) AS lowBalance FROM auth_users`,
|
|
);
|
|
return { users: { total: Number(row.total ?? 0), lowBalance: Number(row.lowBalance ?? 0) }, routes: { total: 0, active: 0 } };
|
|
}
|
|
|
|
return {
|
|
hashPassword,
|
|
capabilityCatalog,
|
|
policyCatalog,
|
|
skillCatalog,
|
|
ensureAdminUser,
|
|
login,
|
|
getMe,
|
|
revoke,
|
|
listUsers,
|
|
createUser,
|
|
updateUser,
|
|
recharge,
|
|
getAdminSummary,
|
|
getRoleCapabilities: async () => ({ ok: true, role: 'user', capabilities: defaultCapabilities() }),
|
|
setRoleCapabilities: async () => ({ ok: true, role: 'user', capabilities: defaultCapabilities() }),
|
|
getUserCapabilities: async () => ({ ok: true, userId: null, capabilities: defaultCapabilities() }),
|
|
setUserCapabilities: async () => ({ ok: true }),
|
|
clearUserCapabilityOverrides: async () => ({ ok: true }),
|
|
getRolePolicies: async () => ({ ok: true, role: 'user', policies: {} }),
|
|
setRolePolicies: async () => ({ ok: true, role: 'user', policies: {} }),
|
|
getUserPolicies: async () => ({ ok: true, userId: null, policies: {} }),
|
|
setUserPolicies: async () => ({ ok: true }),
|
|
clearUserPolicyOverrides: async () => ({ ok: true }),
|
|
getRoleSkills: async () => ({ ok: true, role: 'user', skills: {} }),
|
|
setRoleSkills: async () => ({ ok: true, role: 'user', skills: {} }),
|
|
getUserSkills: async () => ({ ok: true, userId: null, skills: {} }),
|
|
setUserSkills: async () => ({ ok: true }),
|
|
clearUserSkillOverrides: async () => ({ ok: true }),
|
|
};
|
|
}
|