feat: 支持多用户与用户级MCP隔离,优化汇总诊断与UI展示

- 多用户支持:新增 user_mcp_tokens 表,实现按用户 Token 哈希隔离 MCP 查询
- 注册控制:支持 ALLOW_REGISTER 环境变量自适应开关多用户注册
- 前端同步:增加跨账号防脏提交安全拦截,优化登录/注册状态提示
- 汇总页优化:移除合计应得卡片冗余说明,诊断卡片新增「综合时薪诊断」
- 文档更新:同步更新 README.md 关于多用户、MCP 与部署说明
This commit is contained in:
OpenClaw Agent
2026-09-14 14:00:07 +08:00
parent ad36640331
commit 53733b39fc
10 changed files with 320 additions and 63 deletions
+14 -8
View File
@@ -6,7 +6,7 @@ import {
hashPassword, verifyPassword, newToken, hashToken, bearerFrom, SESSION_TTL_MS
} from './auth.js';
import {
readMcpConfig, saveMcpToken, setMcpEnabled, newMcpToken, maskToken
readUserMcpConfig, saveUserMcpToken, setUserMcpEnabled, newMcpToken, maskToken
} from './mcp.js';
import {
BadRequest, normalizeRecord, normalizeAdjust, normalizeSettings,
@@ -102,7 +102,13 @@ export async function handleApi(request, store, opts = {}) {
try {
// ---------- 健康检查 ----------
if (path === '/api/health' && method === 'GET') {
return json({ ok: true, api: API_VERSION, users: await store.countUsers(), now: new Date().toISOString() });
return json({
ok: true,
api: API_VERSION,
users: await store.countUsers(),
allowRegister: !opts.allowRegister ? (await store.countUsers()) === 0 : true,
now: new Date().toISOString()
});
}
// ---------- 注册 ----------
@@ -238,13 +244,13 @@ export async function handleApi(request, store, opts = {}) {
}
// ---------- MCP 接入管理(登录态即可操作;单人系统无特权分级) ----------
// 开关与令牌存 DB,改动即时生效;令牌明文只在 POST /api/mcp/token 的响应里出现一次。
// 开关与令牌存 user_mcp_tokens 表,每个用户独立隔离;令牌明文只在 POST /api/mcp/token 的响应里出现一次。
if (path.startsWith('/api/mcp/')) {
await requireAuth(store, request);
const user = await requireAuth(store, request);
const sub = path.slice('/api/mcp/'.length);
if (sub === 'status' && method === 'GET') {
const cfg = await readMcpConfig(store, opts.mcpSeedToken);
const cfg = await readUserMcpConfig(store, user.id, opts.mcpSeedToken);
return json({
ok: true, configured: cfg.configured, enabled: cfg.enabled,
masked: cfg.configured ? maskToken(cfg.token) : null
@@ -252,18 +258,18 @@ export async function handleApi(request, store, opts = {}) {
}
if (sub === 'token' && method === 'POST') {
const token = newMcpToken();
await saveMcpToken(store, token);
await saveUserMcpToken(store, user.id, token);
return json({ ok: true, token, enabled: true, note: '明文仅此一次展示,请立即保存' });
}
if (sub === 'token' && method === 'GET') {
const cfg = await readMcpConfig(store, opts.mcpSeedToken);
const cfg = await readUserMcpConfig(store, user.id, opts.mcpSeedToken);
if (!cfg.configured) return err('尚未配置 MCP 令牌,请先生成', 404);
return json({ ok: true, token: cfg.token });
}
if (sub === 'enabled' && method === 'POST') {
const body = await readJson(request);
if (typeof body.enabled !== 'boolean') return err('enabled 必须是布尔值');
await setMcpEnabled(store, body.enabled);
await setUserMcpEnabled(store, user.id, body.enabled);
return json({ ok: true, enabled: body.enabled });
}
return err('接口不存在: ' + path, 404);
+71 -32
View File
@@ -27,6 +27,7 @@
import { timingSafeEqual } from 'node:crypto';
import payroll from '../../shared/payroll.js';
import { hashToken } from './auth.js';
const SERVER_INFO = { name: 'work-hours-tracker-mcp', version: '1.0.0' };
// 与 MCP 官方已发布版本对齐;客户端发来的版本若认得就原样回,否则回兜底版本。
@@ -170,32 +171,47 @@ export function newMcpToken() {
}
/**
* 读生效配置:{ configured, enabled, token }。
* 状态语义刻意分开:未配置 → 端点 404(视为不存在);已配置但关闭 → 503
* (「配了但被关了,可从后台重新打开」);令牌错 → 401。
* @param {object} store
* @param {string} seedToken env 里的首启令牌;入口层传入,core 不直接读 process.env(保持平台无关)
* 读指定用户的生效配置:{ configured, enabled, token }。
* 兼容旧数据与种子:如果 user_mcp_tokens 查无记录,但 app_settings/seedToken 中存在旧 token,
* 则自动为 id=1 的老用户迁入,保证向后无感兼容。
*/
export async function readMcpConfig(store, seedToken = '') {
let token = await store.getSetting(MCP_KEYS.token);
if (!token && seedToken) {
// 首次种子:env 有令牌且库里从没有过 → 迁入 DB(兼容旧部署,现有 token 无感切换)
await store.setSetting(MCP_KEYS.token, seedToken);
token = seedToken;
export async function readUserMcpConfig(store, userId, seedToken = '') {
let cfg = await store.getUserMcpConfig(userId);
if (cfg && cfg.configured) return cfg;
// 兼容老单人自用历史数据迁移
if (userId === 1) {
let legacyToken = await store.getSetting(MCP_KEYS.token);
if (!legacyToken && seedToken) {
legacyToken = seedToken;
await store.setSetting(MCP_KEYS.token, legacyToken);
}
if (legacyToken) {
const legacyEnabled = (await store.getSetting(MCP_KEYS.enabled)) !== '0';
const tokenHash = await hashToken(legacyToken);
await store.saveUserMcpToken(1, legacyToken, tokenHash);
if (!legacyEnabled) {
await store.setUserMcpEnabled(1, false);
}
return {
configured: true,
enabled: legacyEnabled,
token: legacyToken
};
}
}
if (!token) return { configured: false, enabled: false, token: null };
const enabled = (await store.getSetting(MCP_KEYS.enabled)) !== '0';
return { configured: true, enabled, token };
return { configured: false, enabled: false, token: null };
}
/** 生成/轮换令牌并自动启用;明文只应出现在本次调用方的响应里,DB 之外不留副本 */
export async function saveMcpToken(store, token) {
await store.setSetting(MCP_KEYS.token, token);
await store.setSetting(MCP_KEYS.enabled, '1');
/** 生成/轮换用户专有令牌并自动启用 */
export async function saveUserMcpToken(store, userId, token) {
const tokenHash = await hashToken(token);
await store.saveUserMcpToken(userId, token, tokenHash);
}
export async function setMcpEnabled(store, enabled) {
await store.setSetting(MCP_KEYS.enabled, enabled ? '1' : '0');
export async function setUserMcpEnabled(store, userId, enabled) {
await store.setUserMcpEnabled(userId, enabled);
}
// ---------- 参数校验 ----------
@@ -533,28 +549,52 @@ async function handleRpcMessage(msg, store, userId) {
/**
* HTTP 入口。
* 用户定位:MCP token 与站点登录态无关;当前是单人自用系统,固定取 id=1 的首个用户
* (与 /api/health 的 users:1 对齐)。多人化时这里要换成 token→user 映射。
* 多用户架构:MCP 请求由 Bearer Token 鉴权,根据 tokenHash 检索绑定的 user。
* 完全解绑单人 id=1 硬编码,不同用户的 AI 客户端独立查各自打卡与薪酬。
* @param {Request} request
* @param {object} store SqliteStore / D1Store 实例
* @param {{seedToken?:string}} opts MCP_AUTH_TOKEN 环境变量值(仅首次种子用)
*/
export async function handleMcp(request, store, opts = {}) {
const cfg = await readMcpConfig(store, opts.seedToken);
if (!cfg.configured) {
return new Response('MCP 未启用:尚未配置接入令牌(页面「设置 → AI 接入 (MCP)」可一键生成)', { status: 404 });
}
if (!cfg.enabled) {
return new Response('MCP 已在后台关闭:可在页面「设置 → AI 接入 (MCP)」重新开启', { status: 503 });
// 全局若未配置过任何可用 token,端点按设计直接关闭(404 隐藏端点)
const activeCount = await store.countActiveMcpTokens();
let legacyCfg1 = null;
if (activeCount === 0) {
legacyCfg1 = await readUserMcpConfig(store, 1, opts.seedToken);
if (!legacyCfg1.configured) {
return new Response('MCP 未启用:尚未配置接入令牌(页面「设置 → AI 接入 (MCP)」可一键生成)', { status: 404 });
}
}
if (request.method !== 'POST') {
return new Response('Method Not Allowed(本端点只接受 POST)', {
status: 405, headers: { allow: 'POST' }
});
}
const provided = (request.headers.get('authorization') || '').replace(/^Bearer\s+/i, '');
if (!provided || !tokenEquals(provided, cfg.token)) {
return json({ error: 'unauthorized' }, 401);
if (!provided) {
return json({ error: 'unauthorized', message: '缺少 Authorization Bearer 令牌' }, 401);
}
const tokenHash = await hashToken(provided);
let hit = await store.getUserByMcpTokenHash(tokenHash);
// 兜底兼容迁移:若尚未迁入 user_mcp_tokens,检查 user 1 是否有遗留配置
if (!hit) {
const cfg1 = legacyCfg1 || await readUserMcpConfig(store, 1, opts.seedToken);
if (cfg1.configured && cfg1.token && tokenEquals(provided, cfg1.token)) {
const u1 = await store.getUserById(1);
if (u1) hit = { user: u1, enabled: cfg1.enabled };
}
}
if (!hit) {
return json({ error: 'unauthorized', message: '无效的 MCP 访问令牌' }, 401);
}
if (!hit.enabled) {
return new Response('MCP 已在后台关闭:可在页面「设置 → AI 接入 (MCP)」重新开启', { status: 503 });
}
let body;
@@ -564,8 +604,7 @@ export async function handleMcp(request, store, opts = {}) {
return json(rpcError(null, -32700, 'JSON 解析失败'), 400);
}
const user = await store.getUserById(1);
if (!user) return json(rpcError(null, -32603, '系统中还没有用户'), 500);
const user = hit.user;
if (Array.isArray(body)) {
const responses = [];
+11 -10
View File
@@ -66,20 +66,21 @@ CREATE TABLE IF NOT EXISTS settings (
updated_at TEXT NOT NULL
);
-- App 级键值:服务自身的运行时配置(当前用于 MCP 接入开关与令牌)。
-- 刻意与 users/settings 分开:它不属于任何用户,不参与同步协议,
-- 客户端 push 碰不到它,增量拉取也不会把它带出去。
-- App 级键值:服务自身的运行时配置(保留用于全局系统配置)
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- App 级键值:服务自身的运行时配置(当前用于 MCP 接入开关与令牌)。
-- 刻意与 users/settings 分开:它不属于任何用户,不参与同步协议,
-- 客户端 push 碰不到它,增量拉取也不会把它带出去。
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
-- 用户级 MCP Token 表:支持多用户独立配置 AI 接入令牌
CREATE TABLE IF NOT EXISTS user_mcp_tokens (
user_id INTEGER PRIMARY KEY REFERENCES users(id),
token TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
);
CREATE INDEX IF NOT EXISTS idx_user_mcp_hash ON user_mcp_tokens(token_hash);
+8 -1
View File
@@ -25,10 +25,17 @@
// // 全量导出,用于备份与首次同步
// fullDump(userId): Promise<{ rev, records, adjust, settings }>
//
// // ---- App 级键值 ----(运行时配置,如 MCP 开关/令牌;不属于任何用户,不参与同步)
// // ---- App 级键值 ----(运行时配置,如全局系统开关;不属于任何用户,不参与同步)
// getSetting(key): Promise<string|null>
// setSetting(key, value): Promise<void>
//
// // ---- 用户级 MCP 接入令牌 ----
// countActiveMcpTokens(): Promise<number>
// getUserMcpConfig(userId): Promise<{ configured: boolean, enabled: boolean, token: string|null }|null>
// saveUserMcpToken(userId, token, tokenHash): Promise<void>
// setUserMcpEnabled(userId, enabled): Promise<void>
// getUserByMcpTokenHash(tokenHash): Promise<{ user: UserRow, enabled: boolean }|null>
//
// UserRow: { id, username, pw_hash, pw_salt, pw_iter, rev, created_at }
export const HOUR_MIN = 6;