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) <noreply@anthropic.com>
16 KiB
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 块,新增两张表:
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 冲突抛出UsernameTakenErrorfindByUsername(username): User | nullverifyPassword(plain, hash): Promise<boolean>— bcryptjs comparecountUsers(): number— 用于/needs-setupchangePassword(userId, newPassword): void
src/data/sessions.ts
createSession(userId): { token: string; expiresAt: number }— 生成 32 字节随机 token(crypto.randomBytes(32).toString('base64url')),存 sha256validateAndTouch(rawToken): { userId, username } | null— 单次 SQL JOIN:查 session + user;过期 → 返回 null + 删除该行;否则若now - lastSeenAt > 1h则 UPDATE 滑动续期到now + 7ddeleteSession(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)
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:
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<User|null>、refresh()、logout()、needsSetup: Ref<boolean>。在 App.vue mount 时调用 refresh()。
路由守卫(web/src/router/index.ts)
- 公共路由:
/login、/setup - 全局
beforeEach:- 先
GET /api/session/needs-setup(仅在needsSetup未知时拉一次并缓存) needsSetup === true且目标不是/setup→redirect('/setup')- 否则
GET /api/session/me,401 且目标非公共路由 →redirect('/login?next=<path>')
- 先
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.nowmock 验证) - 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 / 已知妥协
- 会话失效不主动断 WS —— 后台 deleteSession 后,已有 WS 仍在跑(直到客户端断或服务端进程重启)。原因:WS 长连接没有"每条消息再次鉴权"的廉价手段;为此引入会浪费时间。影响面有限:WS 只推状态、不接收 mutating 命令;所有写操作仍走 HTTP。
- 无登录限流 —— 见 Out of Scope。若部署面向公网,建议在反代层加 limit(如 nginx
limit_req)。 config.adminPassword留作未使用字段 —— 不迁移、不读取。后续 PR 可移除并加 schema migration。当前保留是为避免破坏旧config.json解析。- 单一管理员模型 —— 多用户表已存在,但 UI 当前不暴露增删用户。下一个 PR 再加用户管理界面。
- Origin/Referer CSRF 检查 —— 不是 token,但配合
SameSite=Lax已能挡掉常规 CSRF 攻击。代价:会拒绝缺 Origin/Referer 的非浏览器客户端 POST 请求(如裸 curl)—— 这是预期行为。