@sleep2agi/commhub-server 0.9.0-preview.69 → 0.9.0-preview.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth.ts +105 -10
- package/src/db.ts +3 -0
- package/src/server.ts +59 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.70",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// 登录会话:闲置过期 + 「登录设备」列表 + 退出其他设备。
|
|
2
|
+
//
|
|
3
|
+
// 生产上 user-login 令牌一次登录一条、从不过期也不回收,泄漏任何一条就等于那个账号(多为管理员)。
|
|
4
|
+
// 这里的每一条在 main 上都是红的:main 没有闲置过期(旧令牌永远有效)、登录响应不带 token_id、
|
|
5
|
+
// /api/auth/sessions* 端点不存在。正控:在用的会话、显式 API 令牌、节点令牌照常可用。
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import { register, createNetworkTokenForNode, createToken } from "./auth.js";
|
|
12
|
+
import { db, hashToken } from "./db.js";
|
|
13
|
+
|
|
14
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-sessions-"));
|
|
15
|
+
let BASE = "";
|
|
16
|
+
let hub: any = null;
|
|
17
|
+
const PW = "SessionsPassw0rd!";
|
|
18
|
+
let USER = "", userId = "", NET = "", ntok = "", apiToken = "";
|
|
19
|
+
|
|
20
|
+
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
|
21
|
+
async function call(token: string, method: string, path: string, body?: unknown, extra: Record<string, string> = {}) {
|
|
22
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
23
|
+
method,
|
|
24
|
+
headers: { ...(token ? auth(token) : {}), ...(body === undefined ? {} : { "Content-Type": "application/json" }), ...extra },
|
|
25
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
26
|
+
});
|
|
27
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
28
|
+
}
|
|
29
|
+
async function loginAs(label?: string, ua = "anet-test-agent/1.0") {
|
|
30
|
+
const r = await call("", "POST", "/api/auth/login", { username: USER, password: PW, ...(label ? { client_label: label } : {}) }, { "User-Agent": ua });
|
|
31
|
+
expect(r.status).toBe(200);
|
|
32
|
+
return r.body as { token: string; token_id: string };
|
|
33
|
+
}
|
|
34
|
+
// 把某条令牌的「最近使用」拨回 days 天前(created_at 一起拨,模拟一条很早签发、之后没再用过的令牌)。
|
|
35
|
+
function ageToken(token: string, days: number) {
|
|
36
|
+
db.run(
|
|
37
|
+
"UPDATE api_tokens SET last_used_at = datetime('now', ?1), created_at = datetime('now', ?1) WHERE token_hash = ?2",
|
|
38
|
+
[`-${days} days`, hashToken(token)],
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
function neverUsed(token: string, days: number) {
|
|
42
|
+
db.run("UPDATE api_tokens SET last_used_at = NULL, created_at = datetime('now', ?1) WHERE token_hash = ?2", [`-${days} days`, hashToken(token)]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
47
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
48
|
+
process.env.HOST = "127.0.0.1";
|
|
49
|
+
// 这里每个用例都真走 POST /api/auth/login 签发会话;默认的每 IP 登录限流会在第 ~20 次开始 429。
|
|
50
|
+
process.env.COMMHUB_LOGIN_IP_MAX = "1000";
|
|
51
|
+
delete process.env.COMMHUB_SESSION_IDLE_DAYS;
|
|
52
|
+
USER = `sess_user_${Date.now()}`;
|
|
53
|
+
const reg = register(USER, PW);
|
|
54
|
+
userId = reg.user!.user_id; NET = reg.network_id!;
|
|
55
|
+
const minted = createNetworkTokenForNode(userId, NET, "sess-node", "node_sess_1");
|
|
56
|
+
expect(minted.ok).toBe(true);
|
|
57
|
+
ntok = minted.token!;
|
|
58
|
+
const api = createToken(userId, "ci-script");
|
|
59
|
+
expect(api.ok).toBe(true);
|
|
60
|
+
apiToken = api.token!;
|
|
61
|
+
const mod: any = await import("./server.js");
|
|
62
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
63
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
64
|
+
}, 30_000);
|
|
65
|
+
|
|
66
|
+
afterAll(() => {
|
|
67
|
+
try { hub?.stop?.(true); } catch {}
|
|
68
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("login / register identify the issued token", () => {
|
|
72
|
+
test("login returns token_id of the token it returned (and stays backward-compatible)", async () => {
|
|
73
|
+
const r = await call("", "POST", "/api/auth/login", { username: USER, password: PW });
|
|
74
|
+
expect(r.status).toBe(200);
|
|
75
|
+
expect(r.body.ok).toBe(true);
|
|
76
|
+
expect(typeof r.body.token).toBe("string");
|
|
77
|
+
expect(r.body.token_id).toMatch(/^tok_/);
|
|
78
|
+
const row = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(r.body.token));
|
|
79
|
+
expect(row.token_id).toBe(r.body.token_id);
|
|
80
|
+
});
|
|
81
|
+
test("register returns token_id", async () => {
|
|
82
|
+
const r = await call("", "POST", "/api/auth/register", { username: `sess_reg_${Date.now()}`, password: PW });
|
|
83
|
+
expect(r.status).toBe(200);
|
|
84
|
+
expect(r.body.token_id).toMatch(/^tok_/);
|
|
85
|
+
const row = db.get<any>("SELECT token_id, name FROM api_tokens WHERE token_hash = ?1", hashToken(r.body.token));
|
|
86
|
+
expect(row.token_id).toBe(r.body.token_id);
|
|
87
|
+
expect(row.name).toBe("user-login");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("sliding idle expiry (default 30 days)", () => {
|
|
92
|
+
test("a login token idle past the window is rejected with token_expired", async () => {
|
|
93
|
+
const s = await loginAs();
|
|
94
|
+
ageToken(s.token, 31);
|
|
95
|
+
const r = await call(s.token, "GET", "/api/auth/me");
|
|
96
|
+
expect(r.status).toBe(401);
|
|
97
|
+
expect(r.body.error).toBe("token_expired");
|
|
98
|
+
// 同一条 401 改写也覆盖走统一 requireAuth 的 REST 端点
|
|
99
|
+
const r2 = await call(s.token, "GET", "/api/sessions");
|
|
100
|
+
expect(r2.status).toBe(401);
|
|
101
|
+
expect(r2.body.error).toBe("token_expired");
|
|
102
|
+
});
|
|
103
|
+
test("a login token that was never used counts idle time from created_at", async () => {
|
|
104
|
+
const s = await loginAs();
|
|
105
|
+
neverUsed(s.token, 45);
|
|
106
|
+
const r = await call(s.token, "GET", "/api/auth/me");
|
|
107
|
+
expect(r.status).toBe(401);
|
|
108
|
+
expect(r.body.error).toBe("token_expired");
|
|
109
|
+
});
|
|
110
|
+
test("an old but recently used login token stays valid (the window slides)", async () => {
|
|
111
|
+
const s = await loginAs();
|
|
112
|
+
// 90 天前签发,2 天前还在用
|
|
113
|
+
db.run("UPDATE api_tokens SET created_at = datetime('now', '-90 days'), last_used_at = datetime('now', '-2 days') WHERE token_hash = ?1", [hashToken(s.token)]);
|
|
114
|
+
const r = await call(s.token, "GET", "/api/auth/me");
|
|
115
|
+
expect(r.status).toBe(200);
|
|
116
|
+
// 使用把 last_used_at 推到现在(超过节流间隔,必须写)
|
|
117
|
+
const row = db.get<any>("SELECT last_used_at >= datetime('now', '-60 seconds') AS fresh FROM api_tokens WHERE token_hash = ?1", hashToken(s.token));
|
|
118
|
+
expect(row.fresh).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
test("last_used_at is written at most once per hour per token", async () => {
|
|
121
|
+
const s = await loginAs();
|
|
122
|
+
db.run("UPDATE api_tokens SET last_used_at = datetime('now', '-10 minutes') WHERE token_hash = ?1", [hashToken(s.token)]);
|
|
123
|
+
const before = db.get<any>("SELECT last_used_at FROM api_tokens WHERE token_hash = ?1", hashToken(s.token)).last_used_at;
|
|
124
|
+
expect((await call(s.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
125
|
+
const after = db.get<any>("SELECT last_used_at FROM api_tokens WHERE token_hash = ?1", hashToken(s.token)).last_used_at;
|
|
126
|
+
expect(after).toBe(before);
|
|
127
|
+
});
|
|
128
|
+
test("a truly invalid token still says invalid, not expired", async () => {
|
|
129
|
+
const r = await call("utok_00000000000000000000000000000000", "GET", "/api/auth/me");
|
|
130
|
+
expect(r.status).toBe(401);
|
|
131
|
+
expect(r.body.error).not.toBe("token_expired");
|
|
132
|
+
});
|
|
133
|
+
test("node tokens and explicit API tokens never idle-expire", async () => {
|
|
134
|
+
neverUsed(ntok, 400);
|
|
135
|
+
neverUsed(apiToken, 400);
|
|
136
|
+
const n = await call(ntok, "GET", "/api/auth/me");
|
|
137
|
+
expect(n.status).toBe(200);
|
|
138
|
+
expect(n.body.credential.kind).toBe("node");
|
|
139
|
+
expect((await call(apiToken, "GET", "/api/auth/me")).status).toBe(200);
|
|
140
|
+
});
|
|
141
|
+
test("COMMHUB_SESSION_IDLE_DAYS configures the window; 0 disables it", async () => {
|
|
142
|
+
const s = await loginAs();
|
|
143
|
+
ageToken(s.token, 10);
|
|
144
|
+
process.env.COMMHUB_SESSION_IDLE_DAYS = "7";
|
|
145
|
+
try {
|
|
146
|
+
expect((await call(s.token, "GET", "/api/auth/me")).body.error).toBe("token_expired");
|
|
147
|
+
process.env.COMMHUB_SESSION_IDLE_DAYS = "0";
|
|
148
|
+
expect((await call(s.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
149
|
+
} finally {
|
|
150
|
+
delete process.env.COMMHUB_SESSION_IDLE_DAYS;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("GET /api/auth/sessions", () => {
|
|
156
|
+
test("lists my live login sessions with label, user agent and is_current", async () => {
|
|
157
|
+
const a = await loginAs("Android · 0.2.150", "okhttp/4");
|
|
158
|
+
const b = await loginAs("macOS · 0.2.150", "Mozilla/5.0 (Macintosh)");
|
|
159
|
+
const dead = await loginAs("stale");
|
|
160
|
+
ageToken(dead.token, 40);
|
|
161
|
+
const r = await call(b.token, "GET", "/api/auth/sessions");
|
|
162
|
+
expect(r.status).toBe(200);
|
|
163
|
+
expect(r.body.idle_timeout_days).toBe(30);
|
|
164
|
+
expect(r.body.current_token_id).toBe(b.token_id);
|
|
165
|
+
const ids = r.body.sessions.map((x: any) => x.token_id);
|
|
166
|
+
expect(ids).toContain(a.token_id);
|
|
167
|
+
expect(ids).toContain(b.token_id);
|
|
168
|
+
expect(ids).not.toContain(dead.token_id);
|
|
169
|
+
const me = r.body.sessions.find((x: any) => x.token_id === b.token_id);
|
|
170
|
+
expect(me.is_current).toBe(true);
|
|
171
|
+
expect(me.client_label).toBe("macOS · 0.2.150");
|
|
172
|
+
expect(me.user_agent).toBe("Mozilla/5.0 (Macintosh)");
|
|
173
|
+
expect(typeof me.created_at).toBe("string");
|
|
174
|
+
const other = r.body.sessions.find((x: any) => x.token_id === a.token_id);
|
|
175
|
+
expect(other.is_current).toBe(false);
|
|
176
|
+
expect(other.client_label).toBe("Android · 0.2.150");
|
|
177
|
+
// 不泄漏令牌本身或哈希;API 令牌和节点令牌不在列表里
|
|
178
|
+
expect(JSON.stringify(r.body)).not.toContain("token_hash");
|
|
179
|
+
expect(JSON.stringify(r.body)).not.toContain(b.token);
|
|
180
|
+
expect(r.body.sessions.every((x: any) => x.name !== "ci-script" && !String(x.name).startsWith("node:"))).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
test("node tokens get 403 user_token_required", async () => {
|
|
183
|
+
const r = await call(ntok, "GET", "/api/auth/sessions");
|
|
184
|
+
expect(r.status).toBe(403);
|
|
185
|
+
expect(r.body.error).toBe("user_token_required");
|
|
186
|
+
});
|
|
187
|
+
test("no token → 401", async () => {
|
|
188
|
+
expect((await call("", "GET", "/api/auth/sessions")).status).toBe(401);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("revoking sessions", () => {
|
|
193
|
+
test("revoke-others keeps the current token and leaves API and node tokens alone", async () => {
|
|
194
|
+
const keep = await loginAs("keep");
|
|
195
|
+
const other1 = await loginAs("other1");
|
|
196
|
+
const other2 = await loginAs("other2");
|
|
197
|
+
const r = await call(keep.token, "POST", "/api/auth/sessions/revoke-others");
|
|
198
|
+
expect(r.status).toBe(200);
|
|
199
|
+
expect(r.body.kept_token_id).toBe(keep.token_id);
|
|
200
|
+
expect(r.body.revoked).toBeGreaterThanOrEqual(2);
|
|
201
|
+
expect((await call(keep.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
202
|
+
expect((await call(other1.token, "GET", "/api/auth/me")).status).toBe(401);
|
|
203
|
+
expect((await call(other2.token, "GET", "/api/auth/me")).status).toBe(401);
|
|
204
|
+
expect((await call(ntok, "GET", "/api/auth/me")).status).toBe(200);
|
|
205
|
+
expect((await call(apiToken, "GET", "/api/auth/me")).status).toBe(200);
|
|
206
|
+
const list = await call(keep.token, "GET", "/api/auth/sessions");
|
|
207
|
+
expect(list.body.sessions.map((x: any) => x.token_id)).toEqual([keep.token_id]);
|
|
208
|
+
const audit = db.get<any>("SELECT detail FROM audit_log WHERE action = 'sessions_revoked_others' AND target_id = ?1", keep.token_id);
|
|
209
|
+
expect(audit?.detail).toMatch(/^revoked=\d+$/);
|
|
210
|
+
});
|
|
211
|
+
test("revoke-others refuses node tokens and non-session tokens", async () => {
|
|
212
|
+
const r = await call(ntok, "POST", "/api/auth/sessions/revoke-others");
|
|
213
|
+
expect(r.status).toBe(403);
|
|
214
|
+
expect(r.body.error).toBe("user_token_required");
|
|
215
|
+
const s = await loginAs("survivor");
|
|
216
|
+
const viaApi = await call(apiToken, "POST", "/api/auth/sessions/revoke-others");
|
|
217
|
+
expect(viaApi.status).toBe(403);
|
|
218
|
+
expect(viaApi.body.error).toBe("session_token_required");
|
|
219
|
+
expect((await call(s.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
220
|
+
});
|
|
221
|
+
test("DELETE /api/auth/sessions/:id revokes one other session", async () => {
|
|
222
|
+
const me = await loginAs("me");
|
|
223
|
+
const victim = await loginAs("victim");
|
|
224
|
+
const r = await call(me.token, "DELETE", `/api/auth/sessions/${victim.token_id}`);
|
|
225
|
+
expect(r.status).toBe(200);
|
|
226
|
+
expect(r.body.was_current).toBe(false);
|
|
227
|
+
expect((await call(victim.token, "GET", "/api/auth/me")).status).toBe(401);
|
|
228
|
+
expect((await call(me.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
229
|
+
expect(db.get<any>("SELECT 1 AS x FROM audit_log WHERE action = 'session_revoked' AND target_id = ?1", victim.token_id)?.x).toBe(1);
|
|
230
|
+
});
|
|
231
|
+
test("DELETE of my own session is a logout", async () => {
|
|
232
|
+
const me = await loginAs("logout");
|
|
233
|
+
const r = await call(me.token, "DELETE", `/api/auth/sessions/${me.token_id}`);
|
|
234
|
+
expect(r.status).toBe(200);
|
|
235
|
+
expect(r.body.was_current).toBe(true);
|
|
236
|
+
expect((await call(me.token, "GET", "/api/auth/me")).status).toBe(401);
|
|
237
|
+
});
|
|
238
|
+
test("DELETE cannot reach API tokens, node tokens, or another user's session", async () => {
|
|
239
|
+
const me = await loginAs("guard");
|
|
240
|
+
const apiRow = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(apiToken));
|
|
241
|
+
const ntokRow = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(ntok));
|
|
242
|
+
expect((await call(me.token, "DELETE", `/api/auth/sessions/${apiRow.token_id}`)).status).toBe(404);
|
|
243
|
+
expect((await call(me.token, "DELETE", `/api/auth/sessions/${ntokRow.token_id}`)).status).toBe(404);
|
|
244
|
+
const stranger = register(`sess_stranger_${Date.now()}`, PW);
|
|
245
|
+
expect((await call(me.token, "DELETE", `/api/auth/sessions/${stranger.token_id}`)).status).toBe(404);
|
|
246
|
+
expect((await call(stranger.token!, "GET", "/api/auth/me")).status).toBe(200);
|
|
247
|
+
expect((await call(ntok, "DELETE", `/api/auth/sessions/${me.token_id}`)).status).toBe(403);
|
|
248
|
+
expect((await call(me.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
249
|
+
});
|
|
250
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -43,6 +43,7 @@ export interface AuthResult {
|
|
|
43
43
|
error?: string;
|
|
44
44
|
user?: AuthUser;
|
|
45
45
|
token?: string; // user token (utok_)
|
|
46
|
+
token_id?: string; // api_tokens.token_id of `token` — lets a client tell which row in GET /api/auth/sessions is itself
|
|
46
47
|
network_token?: string; // network token (ntok_) for default network
|
|
47
48
|
network_id?: string;
|
|
48
49
|
// #261 P0-2 (2026-06-28): true when the logged-in user still has the
|
|
@@ -61,7 +62,7 @@ function validatePasswordStrength(password: string, label = "password"): string
|
|
|
61
62
|
return null;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
export function register(username: string, password: string, email?: string, displayName?: string, opts: { issueTokens?: boolean } = {}): AuthResult {
|
|
65
|
+
export function register(username: string, password: string, email?: string, displayName?: string, opts: { issueTokens?: boolean; client?: SessionClientInfo } = {}): AuthResult {
|
|
65
66
|
if (!username || username.length < 2) return { ok: false, error: "username must be at least 2 characters" };
|
|
66
67
|
if (username.length > 50) return { ok: false, error: "username too long (max 50)" };
|
|
67
68
|
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
|
|
@@ -135,8 +136,8 @@ export function register(username: string, password: string, email?: string, dis
|
|
|
135
136
|
const userToken = generateUserToken();
|
|
136
137
|
const userTokenId = generateId("tok");
|
|
137
138
|
db.run(
|
|
138
|
-
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
139
|
-
[userTokenId, hashToken(userToken), userId, null, "user-login", "user"]
|
|
139
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
140
|
+
[userTokenId, hashToken(userToken), userId, null, "user-login", "user", cleanClientField(opts.client?.label, 64), cleanClientField(opts.client?.userAgent, 256)]
|
|
140
141
|
);
|
|
141
142
|
|
|
142
143
|
// Network token (ntok_) — bound to default network, for agent-node
|
|
@@ -151,12 +152,13 @@ export function register(username: string, password: string, email?: string, dis
|
|
|
151
152
|
ok: true,
|
|
152
153
|
user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: isFirstUser ? "admin" : "user" },
|
|
153
154
|
token: userToken,
|
|
155
|
+
token_id: userTokenId,
|
|
154
156
|
network_token: networkToken,
|
|
155
157
|
network_id: networkId,
|
|
156
158
|
};
|
|
157
159
|
}
|
|
158
160
|
|
|
159
|
-
export function login(username: string, password: string): AuthResult {
|
|
161
|
+
export function login(username: string, password: string, client: SessionClientInfo = {}): AuthResult {
|
|
160
162
|
const user = db.get<any>(
|
|
161
163
|
"SELECT user_id, username, password_hash, display_name, email, role, must_change_password FROM users WHERE username = ?1",
|
|
162
164
|
username);
|
|
@@ -200,8 +202,8 @@ export function login(username: string, password: string): AuthResult {
|
|
|
200
202
|
const userToken = generateUserToken();
|
|
201
203
|
const tokenId = generateId("tok");
|
|
202
204
|
db.run(
|
|
203
|
-
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
204
|
-
[tokenId, hashToken(userToken), user.user_id, null, "user-login", "user"]
|
|
205
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
206
|
+
[tokenId, hashToken(userToken), user.user_id, null, "user-login", "user", cleanClientField(client.label, 64), cleanClientField(client.userAgent, 256)]
|
|
205
207
|
);
|
|
206
208
|
|
|
207
209
|
// Find default network
|
|
@@ -217,6 +219,7 @@ export function login(username: string, password: string): AuthResult {
|
|
|
217
219
|
ok: true,
|
|
218
220
|
user: { user_id: user.user_id, username: user.username, display_name: user.display_name, email: user.email, role: user.role },
|
|
219
221
|
token,
|
|
222
|
+
token_id: tokenId,
|
|
220
223
|
network_id: networkId,
|
|
221
224
|
// #261 P0-2 — only include field when truthy (back-compat; old clients
|
|
222
225
|
// don't see this field at all unless their account is flagged).
|
|
@@ -288,16 +291,64 @@ export function createNetworkTokenForNode(userId: string, networkId: string, nod
|
|
|
288
291
|
return { ok: true, token, token_id: tokenId, ...(nodeId ? { node_id: nodeId } : {}) };
|
|
289
292
|
}
|
|
290
293
|
|
|
294
|
+
// ── 登录会话(login session)令牌 ──
|
|
295
|
+
// 「会话令牌」= scope='user' 且不绑网络的 utok_:登录 / 注册签发的 user-login、改密码后换发的
|
|
296
|
+
// password-change、管理员重置签发的 admin-reset。它们代表「某台设备上的一次登录」,
|
|
297
|
+
// 由 GET /api/auth/sessions 列出、可被「退出其他设备」撤销,并且**闲置 N 天自动过期**(滑动窗口,
|
|
298
|
+
// 以 last_used_at 计,从没用过的以 created_at 计)。
|
|
299
|
+
// 不受影响:POST /api/auth/tokens 显式创建的 API 令牌(scope='full')、节点 / 网络令牌(network_id 非空)。
|
|
300
|
+
// 它们是给脚本和节点长期用的凭据,由各自的管理入口撤销,不该因为一段时间没被用就失效。
|
|
301
|
+
const SESSION_TOKEN_SQL = "scope = 'user' AND network_id IS NULL";
|
|
302
|
+
// last_used_at 最多每小时写一次:每个请求都会解析令牌,原来每次都 UPDATE。
|
|
303
|
+
// 以小时为粒度对一个以天计的闲置窗口没有影响。
|
|
304
|
+
const LAST_USED_WRITE_INTERVAL_SECONDS = 3600;
|
|
305
|
+
|
|
306
|
+
export type SessionClientInfo = { label?: unknown; userAgent?: unknown };
|
|
307
|
+
|
|
308
|
+
function cleanClientField(value: unknown, max: number): string | null {
|
|
309
|
+
if (typeof value !== "string") return null;
|
|
310
|
+
const cleaned = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, max);
|
|
311
|
+
return cleaned || null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 会话令牌闲置多少天后过期;COMMHUB_SESSION_IDLE_DAYS,默认 30,0 = 关闭闲置过期。每次读,测试可以改。 */
|
|
315
|
+
export function sessionIdleDays(): number {
|
|
316
|
+
const raw = process.env.COMMHUB_SESSION_IDLE_DAYS;
|
|
317
|
+
if (raw === undefined || raw.trim() === "") return 30;
|
|
318
|
+
const n = Number(raw);
|
|
319
|
+
return Number.isFinite(n) && n >= 0 ? n : 30;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// datetime('now', ?N) 的偏移参数;PgAdapter 把它译成 NOW() + $N::INTERVAL。
|
|
323
|
+
function idleCutoffOffset(): string | null {
|
|
324
|
+
const days = sessionIdleDays();
|
|
325
|
+
return days > 0 ? `-${Math.round(days * 86400)} seconds` : null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 已按闲置过期的会话令牌:只有 resolveToken 拒绝之后才查,用来把 401 说清楚是「过期」而不是「无效」。
|
|
329
|
+
export function isExpiredSessionToken(token: string): boolean {
|
|
330
|
+
const cutoff = idleCutoffOffset();
|
|
331
|
+
if (!token || !cutoff) return false;
|
|
332
|
+
const row = db.get<any>(
|
|
333
|
+
`SELECT token_id FROM api_tokens
|
|
334
|
+
WHERE token_hash = ?1 AND ${SESSION_TOKEN_SQL} AND revoked_at IS NULL
|
|
335
|
+
AND COALESCE(last_used_at, created_at) < datetime('now', ?2)`,
|
|
336
|
+
hashToken(token), cutoff);
|
|
337
|
+
return !!row;
|
|
338
|
+
}
|
|
339
|
+
|
|
291
340
|
export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null; tokenId: string | null } | null {
|
|
292
341
|
const tHash = hashToken(token);
|
|
342
|
+
const cutoff = idleCutoffOffset();
|
|
293
343
|
const row = db.get<any>(
|
|
294
344
|
`SELECT t.token_id, t.user_id, t.network_id, t.scope, t.name AS token_name,
|
|
295
345
|
u.username, u.display_name, u.email, u.role
|
|
296
346
|
FROM api_tokens t JOIN users u ON t.user_id = u.user_id
|
|
297
347
|
WHERE t.token_hash = ?1
|
|
298
348
|
AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))
|
|
299
|
-
AND t.revoked_at IS NULL
|
|
300
|
-
|
|
349
|
+
AND t.revoked_at IS NULL${cutoff ? `
|
|
350
|
+
AND NOT (t.scope = 'user' AND t.network_id IS NULL AND COALESCE(t.last_used_at, t.created_at) < datetime('now', ?2))` : ""}`,
|
|
351
|
+
...(cutoff ? [tHash, cutoff] : [tHash]));
|
|
301
352
|
|
|
302
353
|
if (!row) return null;
|
|
303
354
|
|
|
@@ -306,8 +357,11 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
|
|
|
306
357
|
// 在这里拒绝,所有 REST / MCP / SSE 路径一起生效(含升级前就签发、后来才被设为受限的令牌)。
|
|
307
358
|
if (row.network_id && isAgentRestricted(row.user_id, row.network_id)) return null;
|
|
308
359
|
|
|
309
|
-
// Update last_used
|
|
310
|
-
db.run(
|
|
360
|
+
// Update last_used(节流:见 LAST_USED_WRITE_INTERVAL_SECONDS)
|
|
361
|
+
db.run(
|
|
362
|
+
"UPDATE api_tokens SET last_used_at = datetime('now') WHERE token_id = ?1 AND (last_used_at IS NULL OR last_used_at < datetime('now', ?2))",
|
|
363
|
+
[row.token_id, `-${LAST_USED_WRITE_INTERVAL_SECONDS} seconds`]
|
|
364
|
+
);
|
|
311
365
|
|
|
312
366
|
return {
|
|
313
367
|
user: { user_id: row.user_id, username: row.username, display_name: row.display_name, email: row.email, role: row.role },
|
|
@@ -432,6 +486,47 @@ export function revokeOtherUserTokens(userId: string, exceptTokenId?: string | n
|
|
|
432
486
|
return result.changes;
|
|
433
487
|
}
|
|
434
488
|
|
|
489
|
+
export type LoginSession = {
|
|
490
|
+
token_id: string;
|
|
491
|
+
name: string;
|
|
492
|
+
created_at: string;
|
|
493
|
+
last_used_at: string | null;
|
|
494
|
+
client_label: string | null;
|
|
495
|
+
user_agent: string | null;
|
|
496
|
+
is_current: boolean;
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
/** 我的登录会话(未过期、未撤销),最近使用的在前。 */
|
|
500
|
+
export function listSessions(userId: string, currentTokenId: string | null): LoginSession[] {
|
|
501
|
+
const cutoff = idleCutoffOffset();
|
|
502
|
+
const rows = db.all<any>(
|
|
503
|
+
`SELECT token_id, name, created_at, last_used_at, client_label, user_agent FROM api_tokens
|
|
504
|
+
WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND revoked_at IS NULL${cutoff ? `
|
|
505
|
+
AND COALESCE(last_used_at, created_at) >= datetime('now', ?2)` : ""}
|
|
506
|
+
ORDER BY COALESCE(last_used_at, created_at) DESC, created_at DESC`,
|
|
507
|
+
...(cutoff ? [userId, cutoff] : [userId]));
|
|
508
|
+
return rows.map((r) => ({
|
|
509
|
+
token_id: r.token_id,
|
|
510
|
+
name: r.name,
|
|
511
|
+
created_at: r.created_at,
|
|
512
|
+
last_used_at: r.last_used_at ?? null,
|
|
513
|
+
client_label: r.client_label ?? null,
|
|
514
|
+
user_agent: r.user_agent ?? null,
|
|
515
|
+
is_current: !!currentTokenId && r.token_id === currentTokenId,
|
|
516
|
+
}));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** 撤销我的一个登录会话;可以是当前这个(= 退出登录)。不是会话令牌(API 令牌 / 节点令牌)时当作不存在。 */
|
|
520
|
+
export function revokeSession(userId: string, tokenId: string): { ok: boolean; error?: string } {
|
|
521
|
+
const result = db.run(`DELETE FROM api_tokens WHERE token_id = ?1 AND user_id = ?2 AND ${SESSION_TOKEN_SQL}`, [tokenId, userId]);
|
|
522
|
+
return result.changes > 0 ? { ok: true } : { ok: false, error: "session_not_found" };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** 退出其他所有设备:撤销我除当前之外的全部登录会话(含已闲置过期的)。API 令牌、节点令牌不动。 */
|
|
526
|
+
export function revokeOtherSessions(userId: string, currentTokenId: string): number {
|
|
527
|
+
return db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND token_id != ?2`, [userId, currentTokenId]).changes;
|
|
528
|
+
}
|
|
529
|
+
|
|
435
530
|
export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null): { ok: boolean; error?: string; revoked?: number } {
|
|
436
531
|
const passwordError = validatePasswordStrength(newPassword, "new password");
|
|
437
532
|
if (passwordError) return { ok: false, error: passwordError };
|
package/src/db.ts
CHANGED
|
@@ -640,6 +640,9 @@ for (const ddl of [
|
|
|
640
640
|
"ALTER TABLE api_tokens ADD COLUMN revoked_at TEXT",
|
|
641
641
|
"ALTER TABLE api_tokens ADD COLUMN role TEXT",
|
|
642
642
|
"ALTER TABLE api_tokens ADD COLUMN bound_node_id TEXT",
|
|
643
|
+
// 登录会话的设备标识(GET /api/auth/sessions 显示用):客户端自报的 client_label + 登录请求的 User-Agent。
|
|
644
|
+
"ALTER TABLE api_tokens ADD COLUMN client_label TEXT",
|
|
645
|
+
"ALTER TABLE api_tokens ADD COLUMN user_agent TEXT",
|
|
643
646
|
]) {
|
|
644
647
|
try { db.exec(ddl); }
|
|
645
648
|
catch (e: any) {
|
package/src/server.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { canMessageAgent, isAgentRestricted, listAgentGrants, getAgentAccessMode
|
|
|
20
20
|
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
21
21
|
import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
|
|
22
22
|
import { daemonDefaultWorkdirRoot } from "./create-node-validate.js";
|
|
23
|
-
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
23
|
+
import { register, login, resolveToken, isExpiredSessionToken, sessionIdleDays, listSessions, revokeSession, revokeOtherSessions, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
24
24
|
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
25
25
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
26
26
|
import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
|
|
@@ -260,6 +260,20 @@ function nodeMayRenameTxn(resolved: { networkId: string | null; tokenName: strin
|
|
|
260
260
|
return self === txn.old_alias || self === txn.new_alias;
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
// 登录会话闲置过期(auth.ts sessionIdleDays):resolveToken 把过期令牌当作不存在,各端点于是回各自的
|
|
264
|
+
// 401("invalid token" / "unauthorized" / "auth required" …)。客户端要据此提示「登录已过期」而不是
|
|
265
|
+
// 「令牌无效」,所以在唯一的出口处统一改写:请求带的令牌恰好是一个已闲置过期的会话令牌时,
|
|
266
|
+
// 401 的正文换成 { error: "token_expired" }。状态码不变;只在已经是 401 时才多查一次库。
|
|
267
|
+
function explainExpiredSession(req: Request, res: Response | undefined): Response | undefined {
|
|
268
|
+
if (!res || res.status !== 401) return res;
|
|
269
|
+
const token = requestToken(req);
|
|
270
|
+
if (!token || !isExpiredSessionToken(token)) return res;
|
|
271
|
+
return withCors(req, Response.json(
|
|
272
|
+
{ ok: false, error: "token_expired", message: "login session expired after inactivity; sign in again" },
|
|
273
|
+
{ status: 401 },
|
|
274
|
+
));
|
|
275
|
+
}
|
|
276
|
+
|
|
263
277
|
function userTokenRequired(req: Request): Response {
|
|
264
278
|
return withCors(req, Response.json({ ok: false, error: "user_token_required", message: "this endpoint acts on a user account; node tokens cannot use it" }, { status: 403 }));
|
|
265
279
|
}
|
|
@@ -848,7 +862,7 @@ return Bun.serve({
|
|
|
848
862
|
|
|
849
863
|
async fetch(req, server) {
|
|
850
864
|
// 2026-09-16 —— 所有 JSON/文本响应在客户端要求时 gzip(见 http-gzip.ts);SSE/流式/二进制原样。
|
|
851
|
-
return maybeGzipResponse(req, await (async (): Promise<Response | undefined> => {
|
|
865
|
+
return maybeGzipResponse(req, explainExpiredSession(req, await (async (): Promise<Response | undefined> => {
|
|
852
866
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
853
867
|
|
|
854
868
|
// ── CORS preflight ──
|
|
@@ -1134,7 +1148,7 @@ return Bun.serve({
|
|
|
1134
1148
|
}
|
|
1135
1149
|
try {
|
|
1136
1150
|
const body = await req.json() as any;
|
|
1137
|
-
const result = register(body.username, body.password, body.email, body.display_name);
|
|
1151
|
+
const result = register(body.username, body.password, body.email, body.display_name, { client: { label: body.client_label, userAgent: req.headers.get("user-agent") } });
|
|
1138
1152
|
if (result.ok) logAudit(result.user!.user_id, body.username, "register", "user", result.user!.user_id);
|
|
1139
1153
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1140
1154
|
} catch (e: any) {
|
|
@@ -1162,7 +1176,7 @@ return Bun.serve({
|
|
|
1162
1176
|
{ status: 429, headers: { "Retry-After": String(Math.ceil((lock.retryAfterMs ?? 1000) / 1000)) } }
|
|
1163
1177
|
));
|
|
1164
1178
|
}
|
|
1165
|
-
const result = login(body.username, body.password);
|
|
1179
|
+
const result = login(body.username, body.password, { label: body.client_label, userAgent: req.headers.get("user-agent") });
|
|
1166
1180
|
if (result.ok) {
|
|
1167
1181
|
sharedLoginFailureLockout.recordSuccess(body.username);
|
|
1168
1182
|
logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
|
|
@@ -1354,6 +1368,46 @@ return Bun.serve({
|
|
|
1354
1368
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 404 }));
|
|
1355
1369
|
}
|
|
1356
1370
|
|
|
1371
|
+
// ── 登录设备:列出 / 撤销我的登录会话(见 auth.ts「登录会话令牌」) ──
|
|
1372
|
+
// 只接受用户令牌:节点令牌不是一次登录,也不能替签发它的人管账号(同 #2086)。
|
|
1373
|
+
if (url.pathname === "/api/auth/sessions" && req.method === "GET") {
|
|
1374
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1375
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1376
|
+
const resolved = resolveToken(token);
|
|
1377
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1378
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1379
|
+
const sessions = listSessions(resolved.user.user_id, resolved.tokenId);
|
|
1380
|
+
return withCors(req, Response.json({ ok: true, sessions, current_token_id: resolved.tokenId, idle_timeout_days: sessionIdleDays() }));
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
if (url.pathname === "/api/auth/sessions/revoke-others" && req.method === "POST") {
|
|
1384
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1385
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1386
|
+
const resolved = resolveToken(token);
|
|
1387
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1388
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1389
|
+
// 当前令牌必须是一次登录会话,否则「保留当前这个」无从说起(API 令牌调用会把所有会话都撤掉)。
|
|
1390
|
+
const current = resolved.tokenId ? listSessions(resolved.user.user_id, resolved.tokenId).find((x) => x.is_current) : undefined;
|
|
1391
|
+
if (!current) return withCors(req, Response.json({ ok: false, error: "session_token_required", message: "call this with a login session token" }, { status: 403 }));
|
|
1392
|
+
const revoked = revokeOtherSessions(resolved.user.user_id, current.token_id);
|
|
1393
|
+
logAudit(resolved.user.user_id, resolved.user.username, "sessions_revoked_others", "token", current.token_id, `revoked=${revoked}`);
|
|
1394
|
+
return withCors(req, Response.json({ ok: true, revoked, kept_token_id: current.token_id }));
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
const sessionDeleteMatch = url.pathname.match(/^\/api\/auth\/sessions\/([^/]+)$/);
|
|
1398
|
+
if (sessionDeleteMatch && req.method === "DELETE") {
|
|
1399
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1400
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1401
|
+
const resolved = resolveToken(token);
|
|
1402
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1403
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1404
|
+
const targetId = decodeURIComponent(sessionDeleteMatch[1]);
|
|
1405
|
+
const result = revokeSession(resolved.user.user_id, targetId);
|
|
1406
|
+
const self = targetId === resolved.tokenId;
|
|
1407
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "session_revoked", "token", targetId, self ? "self (logout)" : undefined);
|
|
1408
|
+
return withCors(req, Response.json({ ...result, ...(result.ok ? { was_current: self } : {}) }, { status: result.ok ? 200 : 404 }));
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1357
1411
|
// ── V3: Network management ──
|
|
1358
1412
|
if (url.pathname === "/api/networks" && req.method === "GET") {
|
|
1359
1413
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
@@ -3990,7 +4044,7 @@ Security: ${SECURITY_LABEL}
|
|
|
3990
4044
|
`,
|
|
3991
4045
|
{ status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } } // #426
|
|
3992
4046
|
));
|
|
3993
|
-
})());
|
|
4047
|
+
})()));
|
|
3994
4048
|
},
|
|
3995
4049
|
|
|
3996
4050
|
// ── WebSocket handler for tmux terminal streaming ──
|