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
+58
View File
@@ -156,4 +156,62 @@ export class D1Store {
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
`).bind(key, value, new Date().toISOString()).run();
}
// ---------- 用户级 MCP 接入令牌 ----------
async countActiveMcpTokens() {
const r = await this.db.prepare('SELECT COUNT(*) AS c FROM user_mcp_tokens WHERE enabled = 1').first();
return r ? r.c : 0;
}
async getUserMcpConfig(userId) {
const r = await this.db.prepare('SELECT * FROM user_mcp_tokens WHERE user_id = ?').bind(userId).first();
if (!r) return { configured: false, enabled: false, token: null };
return {
configured: true,
enabled: r.enabled !== 0,
token: r.token
};
}
async saveUserMcpToken(userId, token, tokenHash) {
const now = new Date().toISOString();
await this.db.prepare(`
INSERT INTO user_mcp_tokens (user_id, token, token_hash, enabled, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
token = excluded.token,
token_hash = excluded.token_hash,
enabled = 1,
updated_at = excluded.updated_at
`).bind(userId, token, tokenHash, now, now).run();
}
async setUserMcpEnabled(userId, enabled) {
const now = new Date().toISOString();
await this.db.prepare(`
UPDATE user_mcp_tokens SET enabled = ?, updated_at = ? WHERE user_id = ?
`).bind(enabled ? 1 : 0, now, userId).run();
}
async getUserByMcpTokenHash(tokenHash) {
const r = await this.db.prepare(`
SELECT u.*, m.enabled AS mcp_enabled
FROM user_mcp_tokens m
JOIN users u ON u.id = m.user_id
WHERE m.token_hash = ?
`).bind(tokenHash).first();
if (!r) return null;
return {
user: {
id: r.id,
username: r.username,
pw_hash: r.pw_hash,
pw_salt: r.pw_salt,
pw_iter: r.pw_iter,
rev: r.rev,
created_at: r.created_at
},
enabled: r.mcp_enabled !== 0
};
}
}
+58
View File
@@ -188,4 +188,62 @@ export class SqliteStore {
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
`).run(key, value, new Date().toISOString());
}
// ---------- 用户级 MCP 接入令牌 ----------
async countActiveMcpTokens() {
const r = this.db.prepare('SELECT COUNT(*) AS c FROM user_mcp_tokens WHERE enabled = 1').get();
return r ? r.c : 0;
}
async getUserMcpConfig(userId) {
const r = this.db.prepare('SELECT * FROM user_mcp_tokens WHERE user_id = ?').get(userId);
if (!r) return { configured: false, enabled: false, token: null };
return {
configured: true,
enabled: r.enabled !== 0,
token: r.token
};
}
async saveUserMcpToken(userId, token, tokenHash) {
const now = new Date().toISOString();
this.db.prepare(`
INSERT INTO user_mcp_tokens (user_id, token, token_hash, enabled, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
token = excluded.token,
token_hash = excluded.token_hash,
enabled = 1,
updated_at = excluded.updated_at
`).run(userId, token, tokenHash, now, now);
}
async setUserMcpEnabled(userId, enabled) {
const now = new Date().toISOString();
this.db.prepare(`
UPDATE user_mcp_tokens SET enabled = ?, updated_at = ? WHERE user_id = ?
`).run(enabled ? 1 : 0, now, userId);
}
async getUserByMcpTokenHash(tokenHash) {
const r = this.db.prepare(`
SELECT u.*, m.enabled AS mcp_enabled
FROM user_mcp_tokens m
JOIN users u ON u.id = m.user_id
WHERE m.token_hash = ?
`).get(tokenHash);
if (!r) return null;
return {
user: {
id: r.id,
username: r.username,
pw_hash: r.pw_hash,
pw_salt: r.pw_salt,
pw_iter: r.pw_iter,
rev: r.rev,
created_at: r.created_at
},
enabled: r.mcp_enabled !== 0
};
}
}
+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;
+47 -2
View File
@@ -39,12 +39,12 @@ async function rpc(msg, token) {
// 管理 API:与 api.test.js 同一套调用模式
let TOKEN = null;
async function call(method, path, body, token = TOKEN) {
async function call(method, path, body, token = TOKEN, opts = { allowRegister: true }) {
const headers = { 'content-type': 'application/json' };
if (token) headers.authorization = `Bearer ${token}`;
const res = await handleApi(new Request('http://t' + path, {
method, headers, body: body === undefined ? undefined : JSON.stringify(body)
}), store, {});
}), store, opts);
let json = null;
try { json = await res.json(); } catch {}
return { status: res.status, body: json };
@@ -168,6 +168,51 @@ section('Token 轮换:旧的立即失效');
eq('取回明文是新 Token', r.body.token, globalThis.__T2);
}
// ==========================================================
section('多用户 MCP 隔离:不同 Token 独立绑定不同用户');
{
// 注册第二个用户 user2
let r = await call('POST', '/api/register', { username: 'alice', password: 'alice-pass-123' }, null, { allowRegister: true });
eq('第二个用户注册 201', r.status, 201);
r = await call('POST', '/api/login', { username: 'alice', password: 'alice-pass-123' }, null);
eq('第二个用户登录 200', r.status, 200);
const user2Token = r.body.token;
// 用户2 推送自己的打卡数据(9/10 10小时)
await call('POST', '/api/push', {
records: {
'2026-09-10': { status: 'work', shift_type: 'day', hours: 10 }
}
}, user2Token);
// 用户1 推送自己的打卡数据(9/10 6小时)
await call('POST', '/api/push', {
records: {
'2026-09-10': { status: 'work', shift_type: 'day', hours: 6 }
}
}, TOKEN);
// 用户2 生成自己的 MCP Token
r = await call('POST', '/api/mcp/token', undefined, user2Token);
eq('用户2 生成 MCP Token 200', r.status, 200);
const user2McpToken = r.body.token;
ok('用户2 的 MCP Token 与用户1 不同', user2McpToken !== globalThis.__T2);
// 用 用户1 的 MCP Token 查 9月总结
const CALL_SUMMARY = {
jsonrpc: '2.0', id: 2, method: 'tools/call',
params: { name: 'get_month_summary', arguments: { year: 2026, month: 9 } }
};
const r1 = await rpc(CALL_SUMMARY, globalThis.__T2);
eq('用户1 MCP 调用成功', r1.status, 200);
eq('用户1 工时是 6h', r1.body.result.structuredContent.hours.total, 6);
// 用 用户2 的 MCP Token 查 9月总结
const r2 = await rpc(CALL_SUMMARY, user2McpToken);
eq('用户2 MCP 调用支持成功', r2.status, 200);
eq('用户2 工时是 10h', r2.body.result.structuredContent.hours.total, 10);
}
// ==========================================================
store.close();
rmSync(tmp, { recursive: true, force: true });