From f7c16888e75797d6bba67b0acb48263090fc8a83 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 27 May 2026 13:07:51 +0800 Subject: [PATCH] docs(spec): WebUI authentication design Spec for adding username+password auth to the WebUI to close the unauthenticated-API exposure (all /api/* and /ws currently open). Design: SQLite users + sessions tables, bcryptjs, 7-day rolling HTTP-only cookie sessions, first-run setup wizard, Origin/Referer CSRF check, WebSocket upgrade gated on the same session cookie. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-27-webui-authentication-design.md | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-webui-authentication-design.md diff --git a/docs/superpowers/specs/2026-05-27-webui-authentication-design.md b/docs/superpowers/specs/2026-05-27-webui-authentication-design.md new file mode 100644 index 0000000..5ae331b --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-webui-authentication-design.md @@ -0,0 +1,360 @@ +# WebUI Authentication + +**Date:** 2026-05-27 +**Status:** Spec — pending implementation +**Branch:** `feat/webui-auth` + +## Problem + +WebUI 的所有后端端点和 WebSocket 当前没有任何鉴权: + +- `src/web/server.ts` 注册的 `/api/bot`、`/api/player`、`/api/music`、`/api/auth`、`/api/config/public-url`、`/api/health`、`/ws` 均无中间件拦截。 +- 静态前端通过 `express.static()` 直接对外提供。 + +后果:任何能访问 WebUI 端口(默认 `3000`)的人都能控制 bot、修改配置、操控播放,并触发对网易云 / QQ / Bilibili 的登录二维码流程。一旦 WebUI 端口暴露公网(无论是直接绑定 `0.0.0.0`、还是经 nginx 反代),即被任意访客接管。 + +## Goal + +为 WebUI 增加用户名 + 密码登录,覆盖所有 HTTP `/api/*` 端点(除显式公共白名单)以及 `/ws` WebSocket,使未登录访客无法调用任何敏感接口或观察 bot 状态。 + +## Out of Scope(明确不做) + +- 登录失败的限流 / 锁定(无 brute-force 防御;可放在反代层;后续 PR 单独做) +- 角色与权限(admin / viewer)—— 全员同权 +- 密码重置流程(不挂邮件;仅提供登录后 `change-password`) +- 双因素认证(2FA) +- "记住我" / 绝对过期 vs 滑动过期的可配置 +- 旧版"无鉴权"兼容开关(`requireAuth=false`)—— 合入后所有部署强制启用鉴权 +- 现有 `config.adminPassword` 字段的迁移 —— 保留为未使用字段,避免破坏旧 `config.json` + +## Non-functional Constraints + +- 不引入需要原生编译的依赖(Windows 用户多,build tools 不稳定)。密码哈希用纯 JS 的 `bcryptjs`。 +- Cookie 行为必须兼容现有 `trustProxy` 反代部署。 +- 升级路径:旧用户首次启动新版本 → 自动进入 `/setup` 创建首位 admin;期间所有 `/api/*` 仍拒绝访问。期间不存在"裸奔窗口"。 +- 后续维护者要能在不阅读 `requireAuth` 内部细节的情况下,把新路由挂到 `/api/*` 下并自动获得鉴权。 + +## Architecture + +### 数据层(`src/data/`) + +扩展 `src/data/database.ts` 的 schema-migration 块,新增两张表: + +```sql +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, -- uuid v4 + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + passwordHash TEXT NOT NULL, -- bcryptjs, 12 rounds + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, -- sha256(rawToken) hex + userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + createdAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, -- ms epoch + lastSeenAt INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId); +CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt); +``` + +**为什么 `sessions.id` 存 sha256(token) 而不是 token 本身:** 若 SQLite 文件被泄露(备份、误传、磁盘扫描),原始 token 会让攻击者直接冒充任意已登录用户。存 hash 后只能爆破。代价仅是每次请求一次 sha256。 + +新模块: + +`src/data/users.ts` +- `createUser(username, password): User` — 在事务里 INSERT;遇到 UNIQUE 冲突抛出 `UsernameTakenError` +- `findByUsername(username): User | null` +- `verifyPassword(plain, hash): Promise` — bcryptjs compare +- `countUsers(): number` — 用于 `/needs-setup` +- `changePassword(userId, newPassword): void` + +`src/data/sessions.ts` +- `createSession(userId): { token: string; expiresAt: number }` — 生成 32 字节随机 token(`crypto.randomBytes(32).toString('base64url')`),存 sha256 +- `validateAndTouch(rawToken): { userId, username } | null` — 单次 SQL JOIN:查 session + user;过期 → 返回 null + 删除该行;否则若 `now - lastSeenAt > 1h` 则 UPDATE 滑动续期到 `now + 7d` +- `deleteSession(rawToken): void` — 退出 +- `deleteAllForUser(userId, exceptToken?): void` — change-password 时调用,可保留当前会话 +- `cleanupExpired(): void` — 定时任务 + +### HTTP 层(`src/web/`) + +#### 新增中间件 + +`src/web/middleware/requireAuth.ts` +``` +读取 req.cookies.tsmb_session + → 缺失 → 401 { error: "unauthenticated" } + → 调 sessions.validateAndTouch + → null → 清 cookie + 401 + → 有效 → req.user = { id, username }; next() +``` + +`src/web/middleware/csrf.ts` +``` +若 method ∈ {GET, HEAD, OPTIONS} → next() +否则要求 req.headers.origin || req.headers.referer 的 host 与 req.get('host') 一致 + → 不一致或两者都缺失 → 403 { error: "bad origin" } +``` + +#### 新路由:`src/web/api/session.ts` + +挂在 `/api/session`,全部公共(不挂 requireAuth): + +| Method | Path | 行为 | +|---|---|---| +| GET | `/needs-setup` | `{ needsSetup: users.countUsers() === 0 }` | +| POST | `/setup` | Body `{ username, password }`。在事务内再次检查 `countUsers() === 0`:是则 INSERT user + 立刻 createSession + Set-Cookie + 200 `{ id, username }`;否则 409 `{ error: "already initialized" }` | +| POST | `/login` | Body `{ username, password }`。匹配则 createSession + Set-Cookie + 200;不匹配则等待 250ms 后 401 `{ error: "invalid credentials" }`(常量时间延迟,降低用户名枚举风险) | +| POST | `/logout` | 删 session,清 cookie,204 | +| GET | `/me` | 走 requireAuth;返回 `{ id, username }` | +| POST | `/change-password` | 走 requireAuth;Body `{ oldPassword, newPassword }`;通过则 changePassword + deleteAllForUser(except 当前) + 204 | + +> `/me` 与 `/change-password` 例外地需要 requireAuth —— 在路由内单独挂中间件,避免污染 `/api/session/*` 的公共属性。 + +#### Cookie 规范 + +- 名称:`tsmb_session` +- 值:32 字节 random → base64url +- 属性:`HttpOnly; SameSite=Lax; Path=/; Max-Age=604800`(7 天) +- `Secure` 标志:当 `req.secure === true`(依赖 `trustProxy` + `X-Forwarded-Proto`);本地 HTTP 调试时不加,避免 cookie 被丢弃 + +#### 装配顺序(`src/web/server.ts`) + +```ts +app.use(express.json({ limit: "400kb" })); +app.use(cookieParser()); // 新增 + +// 公共 +app.get("/api/health", …); +app.get("/api/config/public-url", …); +app.use("/api/session", createSessionRouter(...)); + +// 闸门(仅作用于下方注册的 /api/* 路由) +app.use("/api", csrfOriginCheck); +app.use("/api", requireAuth); + +// 受保护 +app.use("/api/bot", createBotRouter(...)); +app.use("/api/music", createMusicRouter(...)); +app.use("/api/player", createPlayerRouter(...)); +app.use("/api/auth", createAuthRouter(...)); // 音乐平台 QR + +// 静态 SPA(公共,前端自行判定登录态后跳转) +app.use(express.static(staticDir)); +app.get(/^(?!\/api|\/ws)/, sendIndex); +``` + +> Express 的 `app.use` 仅对匹配前缀生效。公共路由先注册即可命中;之后的 `app.use("/api", …)` 闸门只在公共路由未匹配时执行,因此 `/api/health`、`/api/config/public-url`、`/api/session/*` 不会被闸门拦截。 + +#### 定时清理 + +`server.start()` 内启动 `setInterval(cleanupExpired, 60 * 60 * 1000)`,`server.stop()` 内 `clearInterval`。 + +### WebSocket 层(`src/web/websocket.ts` + `src/web/server.ts`) + +改造为手动 upgrade: + +```ts +const wss = new WebSocketServer({ noServer: true }); + +server.on("upgrade", (req, socket, head) => { + if (req.url !== "/ws") { socket.destroy(); return; } + const session = validateCookieFromHeaders(req.headers.cookie); + if (!session) { + socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + (ws as any).userId = session.userId; + wss.emit("connection", ws, req); + }); +}); +``` + +`validateCookieFromHeaders` 在 `src/web/auth/validateSession.ts` 提供,HTTP 中间件与 WS upgrade 共用同一实现,确保不会出现"HTTP 拒、WS 放行"或反之的偏差。 + +不需要在 upgrade 上单独做 CSRF:浏览器在跨站 WebSocket 请求里仍会带 Origin 头,可在 validate 之外顺手比对 `req.headers.origin` host 与 `req.headers.host` 一致;不一致直接拒绝。 + +### 前端层(`web/`) + +#### 新视图 + +- `web/src/views/Login.vue` — 用户名 + 密码表单 → POST `/api/session/login` → 成功跳 `next` 或 `/` +- `web/src/views/FirstRunSetup.vue` — 同样表单 + 二次确认密码 → POST `/api/session/setup` → 成功后自动登录并跳 `/` + - 名称避免与既有 `Setup.vue`(bot 创建向导)冲突 + +#### Session 状态 + +新增 `web/src/composables/useSession.ts`:暴露 `currentUser: Ref`、`refresh()`、`logout()`、`needsSetup: Ref`。在 `App.vue` mount 时调用 `refresh()`。 + +#### 路由守卫(`web/src/router/index.ts`) + +- 公共路由:`/login`、`/setup` +- 全局 `beforeEach`: + 1. 先 `GET /api/session/needs-setup`(仅在 `needsSetup` 未知时拉一次并缓存) + 2. `needsSetup === true` 且目标不是 `/setup` → `redirect('/setup')` + 3. 否则 `GET /api/session/me`,401 且目标非公共路由 → `redirect('/login?next=')` + +#### API 客户端 + +- 所有 `fetch` 改为 `credentials: 'same-origin'`(若现有有 wrapper 则改一处;否则按文件逐个改 —— 实施时由 plan 列出) +- 包一层 401 拦截器:任意受保护请求返回 401 → 清 `currentUser` → `router.push('/login')` + +#### UI + +- 顶栏新增已登录用户名 + "退出"按钮(POST `/logout` → `router.push('/login')`) +- 修改密码入口暂放在已有的"设置"页签内(若无则新增极简 section) + +### 依赖 + +新增到 `package.json`: + +``` +"bcryptjs": "^2.4.3", +"cookie-parser": "^1.4.6", +"@types/bcryptjs": "^2.4.6", +"@types/cookie-parser": "^1.4.7" +``` + +不引入 `express-session`、`jsonwebtoken`、`passport` 等更大栈。 + +## Data Flow + +### 首次启动 + +``` +Browser → GET / → static SPA +SPA mounted → GET /api/session/needs-setup → { needsSetup: true } +SPA → router.replace('/setup') +User submits form → POST /api/session/setup +Server (TX): countUsers() === 0 → INSERT user → createSession → Set-Cookie → 200 +SPA → currentUser refresh → router.replace('/') +``` + +### 已部署用户升级 + +旧 `config.adminPassword` 字段保留不动;首次启动新版本仍会因 `users` 表为空而进入 setup 流程 —— 旧字段不被采纳,避免歧义。 + +### 后续登录 + +``` +SPA → GET /api/session/me → 401 +SPA → router.replace('/login?next=/queue') +User submits → POST /api/session/login → Set-Cookie + 200 +SPA → currentUser refresh → router.replace('/queue') +``` + +### 受保护请求 + +``` +SPA → fetch('/api/bot', { credentials: 'same-origin' }) +Server requireAuth: validateAndTouch(cookie) + → ok → req.user 注入 → 业务路由处理 + → 不 ok → 401 → SPA 拦截器跳 /login +``` + +### WebSocket + +``` +SPA → new WebSocket(`${wsScheme}://${host}/ws`) // 浏览器自动带 cookie +Server upgrade handler: validateCookieFromHeaders + → ok → handleUpgrade → connection event + → 不 ok → HTTP 401 写回原始 socket → destroy +``` + +## Error Handling + +| 场景 | HTTP 响应 | 备注 | +|---|---|---| +| 未带 cookie | 401 `{ error: "unauthenticated" }` | requireAuth | +| Cookie 解析失败 / token 不存在 | 401 + `Set-Cookie tsmb_session=; Max-Age=0` 清掉 | 自愈 | +| Session 过期 | 同上 + DELETE 该行 | validateAndTouch 内部完成 | +| 用户名/密码不匹配 | 401 `{ error: "invalid credentials" }` + 250ms 延迟 | 不区分"用户不存在"和"密码错"两类 | +| `setup` 时已存在用户 | 409 `{ error: "already initialized" }` | 防止重复初始化 | +| `setup` 用户名重复 | 在 `/setup` 流程中不可能(只允许 0 → 1) | | +| `change-password` 旧密码错 | 401 `{ error: "invalid credentials" }` | | +| CSRF Origin 不匹配 | 403 `{ error: "bad origin" }` | | +| WS 无 cookie / 校验失败 | 写回 HTTP/1.1 401 并 destroy socket | 在握手前拒绝,避免 onopen 假成功 | + +所有错误响应统一 `{ error: string }` 形式,匹配现有 API 风格。 + +## Testing Strategy + +### 单元(vitest) + +`src/data/users.test.ts` +- createUser 成功后 findByUsername 命中(大小写不敏感) +- 重复 username 抛 UsernameTakenError +- verifyPassword 正反例 +- changePassword 之后旧哈希不再验证通过 + +`src/data/sessions.test.ts` +- createSession 返回的 token 不是 DB 内 id(DB 内是 sha256(token)) +- validateAndTouch 过期记录返回 null 且记录被删 +- validateAndTouch 未过 1h 不写 DB;过 1h 后写 DB(用 `Date.now` mock 验证) +- deleteAllForUser(exceptToken) 保留指定会话 + +### 集成(vitest + supertest,真 SQLite in-memory) + +`src/web/api/session.test.ts` +- empty DB → /needs-setup 返回 true;/setup 成功;/needs-setup 再调返回 false;二次 /setup 返回 409 +- /login 成功后受保护路由 (`GET /api/bot`) 200;不带 cookie 401 +- /logout 之后同一 cookie 调受保护路由 401 +- /change-password 后 a) 旧密码 /login 失败 b) 新密码 /login 成功 c) 之前签发的其他 cookie 失效,当前 cookie 仍可用 + +`src/web/middleware/csrf.test.ts` +- 带匹配 Origin 的 POST 通过 +- Origin 与 host 不匹配 → 403 +- 同样规则适用 Referer +- GET 永远通过 + +`src/web/websocket.test.ts`(新增或扩展) +- 无 cookie 的 ws 握手 → 收到 HTTP 401,socket 关闭 +- 带有效 cookie → 握手成功,收到 init 消息 +- Session 删除后已建立的 ws **不会**被主动断(明确记录此妥协 —— 见 Trade-offs) + +### 前端 + +不在本 PR 引入新的 e2e 框架。手动用例(在 PR 描述里列): +- 全新数据库启动 → 自动跳 /setup → 创建账户 → 进入主界面 +- 退出 → 自动跳 /login +- 关闭浏览器 7 天内再开 → 仍登录 +- 登录态下后端重启清空 sessions → 任意 API 调用 → 自动跳 /login + +## Files Changed + +``` +src/data/database.ts (schema migration) +src/data/users.ts (new) +src/data/users.test.ts (new) +src/data/sessions.ts (new) +src/data/sessions.test.ts (new) +src/web/auth/validateSession.ts (new, shared by HTTP + WS) +src/web/middleware/requireAuth.ts (new) +src/web/middleware/csrf.ts (new) +src/web/middleware/csrf.test.ts (new) +src/web/api/session.ts (new) +src/web/api/session.test.ts (new) +src/web/server.ts (cookieParser + 公共白名单 + 闸门 + cleanup interval + WS upgrade 重构调用) +src/web/websocket.ts (移除被动 path 绑定;改为 handleUpgrade 模式) +src/web/websocket.test.ts (新增 / 扩展) +package.json (deps) + +web/src/views/Login.vue (new) +web/src/views/FirstRunSetup.vue (new) +web/src/composables/useSession.ts (new) +web/src/router/index.ts (公共路由 + beforeEach 守卫) +web/src/api/*.ts (credentials: 'same-origin' + 401 拦截) +web/src/App.vue (顶栏 logout + 当前用户名) +``` + +## Trade-offs / 已知妥协 + +1. **会话失效不主动断 WS** —— 后台 deleteSession 后,已有 WS 仍在跑(直到客户端断或服务端进程重启)。原因:WS 长连接没有"每条消息再次鉴权"的廉价手段;为此引入会浪费时间。影响面有限:WS 只推状态、不接收 mutating 命令;所有写操作仍走 HTTP。 +2. **无登录限流** —— 见 Out of Scope。若部署面向公网,建议在反代层加 limit(如 nginx `limit_req`)。 +3. **`config.adminPassword` 留作未使用字段** —— 不迁移、不读取。后续 PR 可移除并加 schema migration。当前保留是为避免破坏旧 `config.json` 解析。 +4. **单一管理员模型** —— 多用户表已存在,但 UI 当前不暴露增删用户。下一个 PR 再加用户管理界面。 +5. **Origin/Referer CSRF 检查** —— 不是 token,但配合 `SameSite=Lax` 已能挡掉常规 CSRF 攻击。代价:会拒绝缺 Origin/Referer 的非浏览器客户端 POST 请求(如裸 curl)—— 这是预期行为。