diff --git a/README.md b/README.md index 09b42f5..e9ffdf8 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ ## 功能特性 +- **WebUI 鉴权(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员),bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节 - **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源 - **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API - **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换 @@ -155,6 +156,49 @@ sudo ./scripts/install.sh > > **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `identity` 字段已经是空的,则**无需任何操作**。完成上述步骤后,按下面对应的系统升级步骤执行即可。 +### 从 WebUI 无鉴权版本升级(重要) + +本次更新引入了**强制 WebUI 鉴权**。从无鉴权旧版本升级后,**WebUI 必须先创建管理员账号才能使用**。所有 `/api/*` 端点(除少量公共白名单)和 `/ws` 现在都需要登录。 + +**升级行为**: + +- 启动时数据库自动迁移:新增 `users`、`sessions`、`user_audit` 三张表;旧的 `bot_instances`、`play_history` 数据**完全保留**。 +- 第一次打开 WebUI 自动跳转到 `/first-run` 引导创建首位管理员(角色固定为 `admin`)。 +- 之后访问任何页面都会校验登录态,未登录跳转 `/login`。 + +**会话与 Cookie**: + +- 登录态保存 7 天,每次请求滚动续期(活跃用户不会被踢出)。 +- 同一账号最多保持 10 个并发会话(超过自动剔除最旧的)。 +- Cookie 设置为 `HttpOnly; SameSite=Lax`,HTTPS 部署需配合 `trustProxy: true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。 + +**多用户与角色**: + +- 角色 `admin`:完整权限(用户管理、审计、机器人、音乐平台、播放控制)。 +- 角色 `member`:除"用户管理"和"操作审计"外的所有功能(适合给团队成员开通播放权)。 +- 在 **设置 → 用户管理**(仅管理员)中添加 / 删除 / 重置密码 / 切换角色。 +- 至少保留一个管理员:系统会阻止删除或降级最后一位管理员。 + +**如何重置忘记的管理员密码**: + +如果你忘记了管理员密码,可以直接编辑 SQLite 数据库 `data/tsmusicbot.db`: + +```bash +# 方案 1:清空所有用户,重新进入 first-run 流程 +sqlite3 data/tsmusicbot.db "DELETE FROM users; DELETE FROM sessions;" +# 然后重启机器人,浏览器再次访问会自动进入 /first-run + +# 方案 2:把指定用户重置为已知密码(密码 'changeme-now' 的 bcrypt 哈希示例如下) +# 先用 node 生成哈希: +node -e "console.log(require('bcryptjs').hashSync('changeme-now', 12))" +# 把输出贴到 SQL 里: +sqlite3 data/tsmusicbot.db "UPDATE users SET passwordHash='' WHERE username='你的用户名';" +``` + +**反向代理用户特别注意**:如果通过 nginx / Caddy / Cloudflare 暴露 WebUI,**必须**在 `config.json` 中设置 `"trustProxy": true`,否则 Cookie 不会带 `Secure` 标志,且登录限流会把所有用户合并到同一个桶。详见下方 [反向代理部署注意事项](#反向代理部署注意事项)。 + +**旧版 `config.adminPassword` / `adminGroups`**:这两个配置项在旧版本中预留但从未实际启用(TS-side admin 命令权限的占位字段)。保留以避免破坏旧 `config.json`,但不再影响任何行为。可以放心忽略。 + ### Windows 用户 ``` @@ -221,10 +265,16 @@ sudo systemctl start tsmusicbot ### 首次配置 -1. 打开 **http://localhost:3000/setup** 进入设置向导 -2. 填写 TeamSpeak 服务器地址(默认端口:9987) -3. 设置机器人昵称 -4. (可选)扫码登录网易云/QQ音乐账号以播放 VIP 歌曲 +1. 启动机器人后打开 **http://localhost:3000/** + - 全新部署:自动跳转 `/first-run`,填写用户名(3-32 字符)和密码(≥8 位)创建首位**管理员**账号 + - 之后所有 WebUI 操作都需要登录,登录态保持 7 天(活动会滚动续期) +2. 在 **设置 → 机器人管理** 中点击"创建新实例",填写: + - TeamSpeak 服务器地址(无端口,仅主机名,例如 `ts.example.com`) + - 端口(默认 9987,自托管或非标准端口请填写实际值) + - 机器人昵称 + - 可选:服务器密码、默认频道 +3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / B 站账号(可选,登录后可播放 VIP 歌曲) +4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员,成员账号可以控制播放但无法管理其他用户 ### WebUI 页面说明 @@ -235,7 +285,7 @@ sudo systemctl start tsmusicbot | **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌) | | **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 | | **历史** | 播放历史记录 | -| **设置** | 主题切换、机器人管理、三平台账号登录、音质选择、命令前缀 | +| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员)/ 操作审计(仅管理员) | ### TeamSpeak 文字命令 @@ -426,6 +476,18 @@ pip install -U yt-dlp } ``` +> **关于 `adminPassword` 和 `adminGroups`**:这两个字段保留是为了兼容旧 `config.json`,但当前版本未使用。WebUI 鉴权改为基于数据库的用户账号系统(见 [首次配置](#首次配置)),无需在 `config.json` 中设置密码。 + +### 反向代理部署注意事项 + +当 WebUI 部署在反向代理(nginx / Caddy / Cloudflare 等)之后时,请务必在 `config.json` 中设置 `"trustProxy": true`: + +- **Cookie Secure 标志**:未启用 `trustProxy` 时,Express 无法从 `X-Forwarded-Proto` 正确判断请求实际是否为 HTTPS,会话 cookie 不会被标记为 `Secure`。 +- **登录限流**:登录限流以 `req.ip` 为键,未启用 `trustProxy` 时所有请求都会被识别为代理本身的 IP,单个攻击者会拖累所有合法用户共用同一个限流桶。 +- **审计日志的客户端 IP**(如果未来添加该字段)也需要 `trustProxy` 才能正确记录。 + +直接暴露端口(无代理)时无需启用该选项。 + ## 常见问题 **Q:支持 TeamSpeak 6 Server 吗?** @@ -465,6 +527,21 @@ A:YouTube 是可选音源,需要手动安装 `yt-dlp`。详见 [可选:You **Q:如何更新到新版本?** A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm start` 重新构建启动。Docker 用户执行 `docker-compose up -d --build`。 +**Q:忘记管理员密码怎么办?** +A:直接操作 SQLite 数据库。最简单的办法是清空 `users` 表然后重新进入 first-run 流程:`sqlite3 data/tsmusicbot.db "DELETE FROM users; DELETE FROM sessions;"`,重启后浏览器会自动跳转 `/first-run` 让你重新创建管理员。详细方法见 [从 WebUI 无鉴权版本升级](#从-webui-无鉴权版本升级重要)。 + +**Q:成员(member)能做什么?不能做什么?** +A:成员可以:管理机器人(启动/停止/创建/编辑)、控制播放(搜索/播放/队列)、登录音乐平台账号、修改自己的密码。成员**不能**:管理其他用户、查看操作审计日志、降级或删除管理员。 + +**Q:如何把某个用户从成员升级为管理员?** +A:管理员登录后进入 **设置 → 用户管理**,点击对应用户的"提升管理员"按钮即可。降级同理("降为成员"按钮)。系统会阻止降级最后一位管理员。 + +**Q:登录之后多久会自动退出?** +A:登录态有效期 7 天,活跃使用会滚动续期(每次受保护请求都会刷新过期时间)。同一账号最多保持 10 个并发会话(多设备登录时超过的会自动剔除最旧的会话)。 + +**Q:部署到公网后如何防止暴力登录?** +A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部署建议同时在反向代理(nginx `limit_req` / Caddy 等)层加一层限流,并启用 HTTPS。反向代理部署务必设置 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。 + ## 参与贡献 1. Fork 本仓库 @@ -479,6 +556,20 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm ### 最新版本 +**WebUI 鉴权与权限系统** + +- **首次运行强制创建管理员账号**:浏览器打开 WebUI 自动跳转 `/first-run`;之后所有 `/api/*`(除少量公共白名单:`/api/health`、`/api/config/public-url`、`/api/session/*`)和 `/ws` 都需要登录。详见 [更新升级 → 从 WebUI 无鉴权版本升级](#从-webui-无鉴权版本升级重要)。 +- **两种角色:admin / member**。`member` 可以管理机器人、控制播放、登录音乐平台账号、修改自己密码,但不能管理其他用户或查看审计日志。`admin` 拥有全部权限。 +- **用户管理 UI**:管理员在 设置 → 用户管理 可以增删用户、切换角色、重置密码。系统强制保留至少一位管理员。 +- **操作审计日志**:管理员在 设置 → 操作审计 可以查看用户管理相关事件(创建、删除、密码重置、角色变更、首位管理员创建、自助修改密码)。 +- **自助修改密码**:所有用户都可在 设置 → 账户 修改自己密码。 +- **会话存储**:服务端 SQLite 表 `sessions`,存储 sha256(token);浏览器只持有原始 token cookie。7 天 TTL,每小时滚动续期。同账号最多 10 个并发会话(超出剔除最旧)。 +- **登录限流**:每 IP 每分钟 5 次 `/login` + 3 次 `/setup`,命中返回 429 + `Retry-After`。 +- **CSRF & 安全头**:所有 mutating 请求强制 `Origin`/`Referer` 同源;响应携带 `X-Frame-Options: DENY` 和 `Content-Security-Policy: frame-ancestors 'none'`(防点击劫持)。 +- **配置变更**:反向代理部署务必 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。`config.adminPassword` / `adminGroups` 字段保留以兼容旧 `config.json`,但不再影响任何行为。 + +### v0.x — Bot Profile 自动更新与协议层升级 + **机器人形象自动更新(Bot Profile)** - **播放时自动更新 TS 形象**:头像(专辑封面缩略图)、昵称(`♪ 歌名 - 歌手 - 原昵称`)、描述(歌曲信息)、Away 状态、频道描述、"正在播放"频道消息,全部随歌曲切换自动更新。 diff --git a/docs/superpowers/plans/2026-05-27-webui-authentication.md b/docs/superpowers/plans/2026-05-27-webui-authentication.md new file mode 100644 index 0000000..0093cc6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-webui-authentication.md @@ -0,0 +1,2215 @@ +# WebUI Authentication Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add username + password auth (multi-user) to the WebUI so that all `/api/*` (except an explicit public whitelist) and the `/ws` WebSocket reject unauthenticated requests, gated by a 7-day rolling cookie session. + +**Architecture:** Two new SQLite tables (`users`, `sessions`) reusing the existing better-sqlite3 instance, bcryptjs password hashing, sha256-hashed session IDs at rest, raw 32-byte random token in an HTTP-only `tsmb_session` cookie. Two Express middlewares (`requireAuth`, `csrfOriginCheck`) gate every protected `/api/*` router. The WebSocket switches from passive `path: "/ws"` binding to manual `server.on("upgrade", …)` so it validates the same cookie before accepting the handshake. Frontend gets a `useSession` composable, a router guard, a login view and a first-run wizard view; the existing `/setup` route (bot-creation wizard) is left untouched and the new admin-setup view is mounted at `/first-run` to avoid name collision. + +**Tech Stack:** TypeScript / Express 5 / better-sqlite3 / `ws` / Vitest / Vue 3 + Vue Router + Pinia / bcryptjs (pure JS, no native build). + +**Spec:** `docs/superpowers/specs/2026-05-27-webui-authentication-design.md` + +**Branch:** `feat/webui-auth` (already created and contains the spec commit `f7c1688`). + +--- + +## Files map + +``` +NEW backend + src/data/users.ts + src/data/users.test.ts + src/data/sessions.ts + src/data/sessions.test.ts + src/web/auth/validateSession.ts + src/web/middleware/requireAuth.ts + src/web/middleware/requireAuth.test.ts + src/web/middleware/csrf.ts + src/web/middleware/csrf.test.ts + src/web/api/session.ts + src/web/api/session.test.ts + src/web/websocket-auth.test.ts + +MODIFIED backend + src/data/database.ts (add users + sessions tables in initTables) + src/web/server.ts (cookieParser, public/protected ordering, cleanup interval, manual ws upgrade) + src/web/websocket.ts (no functional change; export setupWebSocket already passes wss) + package.json (deps) + +NEW frontend + web/src/views/Login.vue + web/src/views/FirstRunSetup.vue + web/src/composables/useSession.ts + web/src/api/http.ts (small fetch wrapper with credentials + 401 handler) + +MODIFIED frontend + web/src/router/index.ts (add /login, /first-run, beforeEach guard) + web/src/App.vue (logout button + username chip in Navbar slot) + web/src/components/Navbar.vue (render the slot for logout/username) +``` + +> Note on `/setup` collision: the existing `Setup.vue` (mounted at `/setup`) is the bot-creation wizard, not admin setup. The new admin first-run wizard uses path `/first-run`. The spec said `/setup`; this plan supersedes it because the path is already taken. + +--- + +## Task 1: Branch state + new dependencies + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Verify branch** + +```bash +git status --short +git branch --show-current +``` +Expected: clean working tree on `feat/webui-auth`. + +- [ ] **Step 2: Install runtime + type deps** + +```bash +npm install bcryptjs@^2.4.3 cookie-parser@^1.4.7 +npm install --save-dev @types/bcryptjs@^2.4.6 @types/cookie-parser@^1.4.8 +``` + +- [ ] **Step 3: Sanity-check installation** + +```bash +node -e "console.log(require('bcryptjs').hashSync('x', 4))" +``` +Expected: a bcrypt hash string beginning with `$2a$04$`. + +- [ ] **Step 4: Commit** + +```bash +git add package.json package-lock.json +git commit -m "deps: add bcryptjs + cookie-parser for WebUI auth" +``` + +--- + +## Task 2: Schema migration for users + sessions + +**Files:** +- Modify: `src/data/database.ts:102-131` (extend `initTables`) +- Modify: `src/data/database.test.ts` (add table-creation assertion) + +- [ ] **Step 1: Write the failing test** + +Add to `src/data/database.test.ts` inside the existing `describe("database", …)` block, after the "creates tables on init" test: + +```ts +it("creates users and sessions tables on init", () => { + const tables = botDb.db + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() as Array<{ name: string }>; + const names = tables.map((t) => t.name); + expect(names).toContain("users"); + expect(names).toContain("sessions"); + + const userCols = botDb.db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>; + const userColNames = userCols.map((c) => c.name).sort(); + expect(userColNames).toEqual(["createdAt", "id", "passwordHash", "updatedAt", "username"]); + + const sessionCols = botDb.db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>; + const sessionColNames = sessionCols.map((c) => c.name).sort(); + expect(sessionColNames).toEqual(["createdAt", "expiresAt", "id", "lastSeenAt", "userId"]); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npx vitest run src/data/database.test.ts +``` +Expected: FAIL on "creates users and sessions tables on init" with assertion error. + +- [ ] **Step 3: Add tables in `initTables`** + +In `src/data/database.ts`, replace the `db.exec(\`...\`)` call inside `initTables` so it ends like this (keep the existing `play_history` and `bot_instances` blocks, append the two new tables to the same string): + +```ts +function initTables(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS play_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + botId TEXT NOT NULL, + songId TEXT NOT NULL, + songName TEXT NOT NULL, + artist TEXT NOT NULL, + album TEXT NOT NULL, + platform TEXT NOT NULL, + coverUrl TEXT NOT NULL, + playedAt TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS bot_instances ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + serverAddress TEXT NOT NULL, + serverPort INTEGER NOT NULL, + nickname TEXT NOT NULL, + defaultChannel TEXT NOT NULL, + channelPassword TEXT NOT NULL, + autoStart INTEGER NOT NULL DEFAULT 0, + serverProtocol TEXT NOT NULL DEFAULT '', + ts6ApiKey TEXT NOT NULL DEFAULT '', + serverPassword TEXT NOT NULL DEFAULT '', + identity TEXT + ); + + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + passwordHash TEXT NOT NULL, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + userId TEXT NOT NULL, + createdAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + lastSeenAt INTEGER NOT NULL, + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId); + CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt); + `); +} +``` + +Also enable FK enforcement in `createDatabase` (better-sqlite3 default is OFF). Add this line **immediately after** `db.pragma("journal_mode = WAL")`: + +```ts +db.pragma("foreign_keys = ON"); +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +npx vitest run src/data/database.test.ts +``` +Expected: all tests PASS, including the new one. + +- [ ] **Step 5: Commit** + +```bash +git add src/data/database.ts src/data/database.test.ts +git commit -m "feat(db): add users and sessions tables for WebUI auth" +``` + +--- + +## Task 3: `src/data/users.ts` — user CRUD + password hashing + +**Files:** +- Create: `src/data/users.ts` +- Create: `src/data/users.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/data/users.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createDatabase, type BotDatabase } from "./database.js"; +import { createUserStore, UsernameTakenError, type UserStore } from "./users.js"; + +describe("UserStore", () => { + let botDb: BotDatabase; + let users: UserStore; + + beforeEach(() => { + botDb = createDatabase(":memory:"); + users = createUserStore(botDb.db); + }); + + afterEach(() => { + botDb.close(); + }); + + it("countUsers is 0 on a fresh db", () => { + expect(users.countUsers()).toBe(0); + }); + + it("createUser stores the user and bumps countUsers", async () => { + const u = await users.createUser("alice", "pw-hunter2"); + expect(u.id).toMatch(/^[0-9a-f-]{36}$/); + expect(u.username).toBe("alice"); + expect(users.countUsers()).toBe(1); + }); + + it("findByUsername is case-insensitive and returns null for missing", async () => { + await users.createUser("Alice", "pw"); + expect(users.findByUsername("ALICE")).not.toBeNull(); + expect(users.findByUsername("alice")).not.toBeNull(); + expect(users.findByUsername("bob")).toBeNull(); + }); + + it("createUser rejects duplicate usernames (case-insensitive)", async () => { + await users.createUser("Alice", "pw"); + await expect(users.createUser("alice", "pw2")).rejects.toBeInstanceOf(UsernameTakenError); + }); + + it("verifyPassword accepts correct password and rejects wrong one", async () => { + await users.createUser("alice", "correct-horse-battery-staple"); + const row = users.findByUsername("alice"); + expect(row).not.toBeNull(); + expect(await users.verifyPassword("correct-horse-battery-staple", row!.passwordHash)).toBe(true); + expect(await users.verifyPassword("wrong", row!.passwordHash)).toBe(false); + }); + + it("changePassword updates the hash so the old password no longer verifies", async () => { + const u = await users.createUser("alice", "old"); + await users.changePassword(u.id, "new"); + const row = users.findByUsername("alice"); + expect(await users.verifyPassword("old", row!.passwordHash)).toBe(false); + expect(await users.verifyPassword("new", row!.passwordHash)).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npx vitest run src/data/users.test.ts +``` +Expected: FAIL — module `./users.js` not found. + +- [ ] **Step 3: Implement `src/data/users.ts`** + +```ts +import { randomUUID } from "node:crypto"; +import type Database from "better-sqlite3"; +import bcrypt from "bcryptjs"; + +const BCRYPT_ROUNDS = 12; + +export interface UserRow { + id: string; + username: string; + passwordHash: string; + createdAt: number; + updatedAt: number; +} + +export interface UserStore { + countUsers(): number; + createUser(username: string, password: string): Promise; + findByUsername(username: string): UserRow | null; + findById(id: string): UserRow | null; + verifyPassword(plain: string, hash: string): Promise; + changePassword(userId: string, newPassword: string): Promise; +} + +export class UsernameTakenError extends Error { + constructor(username: string) { + super(`username taken: ${username}`); + this.name = "UsernameTakenError"; + } +} + +export function createUserStore(db: Database.Database): UserStore { + const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users"); + const insertStmt = db.prepare( + "INSERT INTO users (id, username, passwordHash, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)" + ); + const findByUsernameStmt = db.prepare( + "SELECT id, username, passwordHash, createdAt, updatedAt FROM users WHERE username = ? COLLATE NOCASE" + ); + const findByIdStmt = db.prepare( + "SELECT id, username, passwordHash, createdAt, updatedAt FROM users WHERE id = ?" + ); + const updatePasswordStmt = db.prepare( + "UPDATE users SET passwordHash = ?, updatedAt = ? WHERE id = ?" + ); + + return { + countUsers() { + return (countStmt.get() as { n: number }).n; + }, + + async createUser(username, password) { + const hash = await bcrypt.hash(password, BCRYPT_ROUNDS); + const id = randomUUID(); + const now = Date.now(); + try { + insertStmt.run(id, username, hash, now, now); + } catch (err) { + const msg = (err as Error).message; + if (msg.includes("UNIQUE") && msg.includes("users.username")) { + throw new UsernameTakenError(username); + } + throw err; + } + return { id, username, passwordHash: hash, createdAt: now, updatedAt: now }; + }, + + findByUsername(username) { + return (findByUsernameStmt.get(username) as UserRow | undefined) ?? null; + }, + + findById(id) { + return (findByIdStmt.get(id) as UserRow | undefined) ?? null; + }, + + verifyPassword(plain, hash) { + return bcrypt.compare(plain, hash); + }, + + async changePassword(userId, newPassword) { + const hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS); + updatePasswordStmt.run(hash, Date.now(), userId); + }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +npx vitest run src/data/users.test.ts +``` +Expected: all 6 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/data/users.ts src/data/users.test.ts +git commit -m "feat(auth): add UserStore with bcryptjs password hashing" +``` + +--- + +## Task 4: `src/data/sessions.ts` — session CRUD + rolling renewal + +**Files:** +- Create: `src/data/sessions.ts` +- Create: `src/data/sessions.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/data/sessions.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createHash } from "node:crypto"; +import { createDatabase, type BotDatabase } from "./database.js"; +import { createUserStore, type UserStore } from "./users.js"; +import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS } from "./sessions.js"; + +function sha256(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +describe("SessionStore", () => { + let botDb: BotDatabase; + let users: UserStore; + let sessions: SessionStore; + let userId: string; + + beforeEach(async () => { + botDb = createDatabase(":memory:"); + users = createUserStore(botDb.db); + sessions = createSessionStore(botDb.db); + const u = await users.createUser("alice", "pw"); + userId = u.id; + }); + + afterEach(() => { + vi.useRealTimers(); + botDb.close(); + }); + + it("createSession returns a raw token whose sha256 matches the DB row id", () => { + const { token } = sessions.createSession(userId); + const row = botDb.db.prepare("SELECT id FROM sessions").get() as { id: string }; + expect(row.id).toBe(sha256(token)); + expect(row.id).not.toBe(token); + }); + + it("validateAndTouch returns the user for a fresh token", () => { + const { token } = sessions.createSession(userId); + const result = sessions.validateAndTouch(token); + expect(result).not.toBeNull(); + expect(result!.userId).toBe(userId); + expect(result!.username).toBe("alice"); + }); + + it("validateAndTouch returns null and deletes the row for an expired session", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + const { token } = sessions.createSession(userId); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000); + expect(sessions.validateAndTouch(token)).toBeNull(); + const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n; + expect(remaining).toBe(0); + }); + + it("validateAndTouch does not write the DB if called again within the touch interval", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + const { token } = sessions.createSession(userId); + const before = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number }; + vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS - 1000); + sessions.validateAndTouch(token); + const after = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number }; + expect(after.lastSeenAt).toBe(before.lastSeenAt); + }); + + it("validateAndTouch writes lastSeenAt and extends expiresAt past the touch interval", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + const { token, expiresAt: initialExpiry } = sessions.createSession(userId); + vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS + 1000); + sessions.validateAndTouch(token); + const row = botDb.db.prepare("SELECT lastSeenAt, expiresAt FROM sessions").get() as { lastSeenAt: number; expiresAt: number }; + expect(row.lastSeenAt).toBe(Date.now()); + expect(row.expiresAt).toBeGreaterThan(initialExpiry); + }); + + it("deleteSession removes the row", () => { + const { token } = sessions.createSession(userId); + sessions.deleteSession(token); + const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n; + expect(remaining).toBe(0); + expect(sessions.validateAndTouch(token)).toBeNull(); + }); + + it("deleteAllForUser keeps the exceptToken session", () => { + const a = sessions.createSession(userId); + const b = sessions.createSession(userId); + sessions.deleteAllForUser(userId, a.token); + expect(sessions.validateAndTouch(a.token)).not.toBeNull(); + expect(sessions.validateAndTouch(b.token)).toBeNull(); + }); + + it("cleanupExpired removes only expired rows", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + sessions.createSession(userId); // expires later + vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000); + sessions.createSession(userId); // fresh + sessions.cleanupExpired(); + const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n; + expect(remaining).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npx vitest run src/data/sessions.test.ts +``` +Expected: FAIL — `./sessions.js` not found. + +- [ ] **Step 3: Implement `src/data/sessions.ts`** + +```ts +import { createHash, randomBytes } from "node:crypto"; +import type Database from "better-sqlite3"; + +export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour + +export interface SessionValidation { + userId: string; + username: string; +} + +export interface SessionStore { + createSession(userId: string): { token: string; expiresAt: number }; + validateAndTouch(rawToken: string): SessionValidation | null; + deleteSession(rawToken: string): void; + deleteAllForUser(userId: string, exceptToken?: string): void; + cleanupExpired(): void; +} + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export function createSessionStore(db: Database.Database): SessionStore { + const insertStmt = db.prepare( + "INSERT INTO sessions (id, userId, createdAt, expiresAt, lastSeenAt) VALUES (?, ?, ?, ?, ?)" + ); + const selectStmt = db.prepare(` + SELECT s.id, s.userId, s.expiresAt, s.lastSeenAt, u.username + FROM sessions s INNER JOIN users u ON u.id = s.userId + WHERE s.id = ? + `); + const deleteByIdStmt = db.prepare("DELETE FROM sessions WHERE id = ?"); + const touchStmt = db.prepare( + "UPDATE sessions SET lastSeenAt = ?, expiresAt = ? WHERE id = ?" + ); + const deleteAllForUserStmt = db.prepare("DELETE FROM sessions WHERE userId = ?"); + const deleteAllForUserExceptStmt = db.prepare( + "DELETE FROM sessions WHERE userId = ? AND id != ?" + ); + const cleanupStmt = db.prepare("DELETE FROM sessions WHERE expiresAt < ?"); + + return { + createSession(userId) { + const token = randomBytes(32).toString("base64url"); + const id = hashToken(token); + const now = Date.now(); + const expiresAt = now + SESSION_TTL_MS; + insertStmt.run(id, userId, now, expiresAt, now); + return { token, expiresAt }; + }, + + validateAndTouch(rawToken) { + if (!rawToken) return null; + const id = hashToken(rawToken); + const row = selectStmt.get(id) as + | { id: string; userId: string; expiresAt: number; lastSeenAt: number; username: string } + | undefined; + if (!row) return null; + const now = Date.now(); + if (row.expiresAt < now) { + deleteByIdStmt.run(id); + return null; + } + if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) { + touchStmt.run(now, now + SESSION_TTL_MS, id); + } + return { userId: row.userId, username: row.username }; + }, + + deleteSession(rawToken) { + deleteByIdStmt.run(hashToken(rawToken)); + }, + + deleteAllForUser(userId, exceptToken) { + if (exceptToken) { + deleteAllForUserExceptStmt.run(userId, hashToken(exceptToken)); + } else { + deleteAllForUserStmt.run(userId); + } + }, + + cleanupExpired() { + cleanupStmt.run(Date.now()); + }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +npx vitest run src/data/sessions.test.ts +``` +Expected: all 8 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/data/sessions.ts src/data/sessions.test.ts +git commit -m "feat(auth): add SessionStore with rolling renewal and at-rest token hashing" +``` + +--- + +## Task 5: `src/web/auth/validateSession.ts` — shared cookie helper + +**Files:** +- Create: `src/web/auth/validateSession.ts` + +> Used by both the HTTP middleware (`requireAuth`) and the WS upgrade handler. Centralised so both paths can never drift apart. + +- [ ] **Step 1: Implement (no separate test — exercised by middleware and ws tests)** + +```ts +import type { SessionStore, SessionValidation } from "../../data/sessions.js"; + +export const SESSION_COOKIE_NAME = "tsmb_session"; + +/** + * Validate the session cookie carried on an arbitrary HTTP-like header bag. + * Used by Express middleware (req.headers.cookie) AND by the raw WebSocket + * upgrade handler (req.headers.cookie) — they share this exact behavior. + */ +export function validateSessionFromHeaders( + rawCookieHeader: string | undefined, + sessions: SessionStore +): SessionValidation | null { + if (!rawCookieHeader) return null; + const token = parseCookie(rawCookieHeader, SESSION_COOKIE_NAME); + if (!token) return null; + return sessions.validateAndTouch(token); +} + +function parseCookie(header: string, name: string): string | null { + for (const part of header.split(";")) { + const trimmed = part.trim(); + const eq = trimmed.indexOf("="); + if (eq < 1) continue; + if (trimmed.slice(0, eq) !== name) continue; + try { + return decodeURIComponent(trimmed.slice(eq + 1)); + } catch { + return null; + } + } + return null; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/web/auth/validateSession.ts +git commit -m "feat(auth): add shared validateSessionFromHeaders helper" +``` + +--- + +## Task 6: `requireAuth` middleware + +**Files:** +- Create: `src/web/middleware/requireAuth.ts` +- Create: `src/web/middleware/requireAuth.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/web/middleware/requireAuth.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import express from "express"; +import cookieParser from "cookie-parser"; +import request from "supertest"; +import { createDatabase, type BotDatabase } from "../../data/database.js"; +import { createUserStore } from "../../data/users.js"; +import { createSessionStore } from "../../data/sessions.js"; +import { createRequireAuth } from "./requireAuth.js"; +import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; + +describe("requireAuth middleware", () => { + let botDb: BotDatabase; + let app: express.Express; + let validToken: string; + + beforeEach(async () => { + botDb = createDatabase(":memory:"); + const users = createUserStore(botDb.db); + const sessions = createSessionStore(botDb.db); + const u = await users.createUser("alice", "pw"); + validToken = sessions.createSession(u.id).token; + + app = express(); + app.use(cookieParser()); + app.use(createRequireAuth(sessions)); + app.get("/protected", (req, res) => { + res.json({ ok: true, user: (req as any).user }); + }); + }); + + afterEach(() => { + botDb.close(); + }); + + it("rejects requests without a session cookie", async () => { + const res = await request(app).get("/protected"); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "unauthenticated" }); + }); + + it("rejects requests with an unknown session cookie", async () => { + const res = await request(app) + .get("/protected") + .set("Cookie", `${SESSION_COOKIE_NAME}=garbage`); + expect(res.status).toBe(401); + }); + + it("allows requests with a valid session cookie and attaches req.user", async () => { + const res = await request(app) + .get("/protected") + .set("Cookie", `${SESSION_COOKIE_NAME}=${validToken}`); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.user.username).toBe("alice"); + }); +}); +``` + +> Add `supertest` as a devDep if not present: +> ```bash +> npm install --save-dev supertest@^7.1.4 @types/supertest@^6.0.3 +> ``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npx vitest run src/web/middleware/requireAuth.test.ts +``` +Expected: FAIL — `./requireAuth.js` does not export `createRequireAuth`. + +- [ ] **Step 3: Implement `src/web/middleware/requireAuth.ts`** + +```ts +import type { Request, Response, NextFunction, RequestHandler } from "express"; +import type { SessionStore } from "../../data/sessions.js"; +import { validateSessionFromHeaders, SESSION_COOKIE_NAME } from "../auth/validateSession.js"; + +declare module "express-serve-static-core" { + interface Request { + user?: { id: string; username: string }; + } +} + +export function createRequireAuth(sessions: SessionStore): RequestHandler { + return function requireAuth(req: Request, res: Response, next: NextFunction) { + const result = validateSessionFromHeaders(req.headers.cookie, sessions); + if (!result) { + res.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); + res.status(401).json({ error: "unauthenticated" }); + return; + } + req.user = { id: result.userId, username: result.username }; + next(); + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +npx vitest run src/web/middleware/requireAuth.test.ts +``` +Expected: all 3 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/middleware/requireAuth.ts src/web/middleware/requireAuth.test.ts package.json package-lock.json +git commit -m "feat(auth): add requireAuth middleware" +``` + +--- + +## Task 7: `csrfOriginCheck` middleware + +**Files:** +- Create: `src/web/middleware/csrf.ts` +- Create: `src/web/middleware/csrf.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; +import { csrfOriginCheck } from "./csrf.js"; + +describe("csrfOriginCheck middleware", () => { + let app: express.Express; + + beforeEach(() => { + app = express(); + app.use(csrfOriginCheck); + app.get("/", (_req, res) => res.json({ ok: true })); + app.post("/", (_req, res) => res.json({ ok: true })); + }); + + it("allows safe methods (GET/HEAD/OPTIONS) without Origin", async () => { + const res = await request(app).get("/"); + expect(res.status).toBe(200); + }); + + it("rejects POST without Origin or Referer", async () => { + const res = await request(app).post("/"); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: "bad origin" }); + }); + + it("accepts POST when Origin host matches request host", async () => { + const res = await request(app) + .post("/") + .set("Host", "example.com") + .set("Origin", "https://example.com"); + expect(res.status).toBe(200); + }); + + it("rejects POST when Origin host does not match request host", async () => { + const res = await request(app) + .post("/") + .set("Host", "example.com") + .set("Origin", "https://evil.com"); + expect(res.status).toBe(403); + }); + + it("accepts POST when Referer host matches and Origin is absent", async () => { + const res = await request(app) + .post("/") + .set("Host", "example.com") + .set("Referer", "https://example.com/some/path"); + expect(res.status).toBe(200); + }); + + it("rejects POST when Referer host does not match", async () => { + const res = await request(app) + .post("/") + .set("Host", "example.com") + .set("Referer", "https://evil.com/some/path"); + expect(res.status).toBe(403); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npx vitest run src/web/middleware/csrf.test.ts +``` +Expected: FAIL — `./csrf.js` not found. + +- [ ] **Step 3: Implement `src/web/middleware/csrf.ts`** + +```ts +import type { Request, Response, NextFunction } from "express"; + +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +/** + * Same-origin CSRF protection. For mutating requests, the Origin or Referer + * header must indicate a host equal to the request's own host. + * + * SameSite=Lax on the session cookie blocks classic cross-site form posts; + * this header check covers the remaining attack surface (fetch from a malicious + * page that omits SameSite-restricted cookies but tries via Origin spoofing + * is not possible — the browser sets Origin). + */ +export function csrfOriginCheck(req: Request, res: Response, next: NextFunction): void { + if (SAFE_METHODS.has(req.method)) { + next(); + return; + } + const expectedHost = req.get("host"); + const originHeader = req.get("origin"); + const refererHeader = req.get("referer"); + const headerHost = hostOf(originHeader) ?? hostOf(refererHeader); + if (!headerHost || !expectedHost || headerHost !== expectedHost) { + res.status(403).json({ error: "bad origin" }); + return; + } + next(); +} + +function hostOf(url: string | undefined): string | null { + if (!url) return null; + try { + return new URL(url).host; + } catch { + return null; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +npx vitest run src/web/middleware/csrf.test.ts +``` +Expected: all 6 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/middleware/csrf.ts src/web/middleware/csrf.test.ts +git commit -m "feat(auth): add csrfOriginCheck middleware" +``` + +--- + +## Task 8: `/api/session` router (login, logout, setup, me, change-password) + +**Files:** +- Create: `src/web/api/session.ts` +- Create: `src/web/api/session.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/web/api/session.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import express from "express"; +import cookieParser from "cookie-parser"; +import request from "supertest"; +import pino from "pino"; +import { createDatabase, type BotDatabase } from "../../data/database.js"; +import { createUserStore, type UserStore } from "../../data/users.js"; +import { createSessionStore, type SessionStore } from "../../data/sessions.js"; +import { createSessionRouter } from "./session.js"; +import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; + +function makeApp(users: UserStore, sessions: SessionStore) { + const app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use("/api/session", createSessionRouter(users, sessions, pino({ level: "silent" }))); + return app; +} + +function extractCookie(res: request.Response): string { + const header = res.headers["set-cookie"]; + const arr = Array.isArray(header) ? header : header ? [header] : []; + const found = arr.find((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`)); + if (!found) throw new Error("no session cookie set"); + return found.split(";")[0]; // "tsmb_session=xxxx" +} + +describe("session router", () => { + let botDb: BotDatabase; + let users: UserStore; + let sessions: SessionStore; + let app: express.Express; + + beforeEach(() => { + botDb = createDatabase(":memory:"); + users = createUserStore(botDb.db); + sessions = createSessionStore(botDb.db); + app = makeApp(users, sessions); + }); + + afterEach(() => botDb.close()); + + it("GET /needs-setup returns true on an empty db", async () => { + const res = await request(app).get("/api/session/needs-setup"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ needsSetup: true }); + }); + + it("POST /setup creates the first admin, logs them in, and returns false from /needs-setup afterwards", async () => { + const setupRes = await request(app) + .post("/api/session/setup") + .send({ username: "alice", password: "hunter2-hunter2" }); + expect(setupRes.status).toBe(200); + expect(setupRes.body.username).toBe("alice"); + extractCookie(setupRes); // throws if missing + + const needs = await request(app).get("/api/session/needs-setup"); + expect(needs.body).toEqual({ needsSetup: false }); + }); + + it("POST /setup returns 409 once a user already exists", async () => { + await users.createUser("admin", "pw"); + const res = await request(app) + .post("/api/session/setup") + .send({ username: "alice", password: "pw" }); + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: "already initialized" }); + }); + + it("POST /login returns 401 with constant-time delay on bad credentials", async () => { + await users.createUser("alice", "correct"); + const start = Date.now(); + const res = await request(app) + .post("/api/session/login") + .send({ username: "alice", password: "wrong" }); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "invalid credentials" }); + expect(Date.now() - start).toBeGreaterThanOrEqual(200); + }, 10_000); + + it("POST /login sets a session cookie on success", async () => { + await users.createUser("alice", "pw"); + const res = await request(app) + .post("/api/session/login") + .send({ username: "alice", password: "pw" }); + expect(res.status).toBe(200); + expect(res.body.username).toBe("alice"); + extractCookie(res); + }); + + it("GET /me returns the current user when cookie is present, 401 otherwise", async () => { + await users.createUser("alice", "pw"); + const loginRes = await request(app) + .post("/api/session/login") + .send({ username: "alice", password: "pw" }); + const cookie = extractCookie(loginRes); + + const me = await request(app).get("/api/session/me").set("Cookie", cookie); + expect(me.status).toBe(200); + expect(me.body.username).toBe("alice"); + + const anon = await request(app).get("/api/session/me"); + expect(anon.status).toBe(401); + }); + + it("POST /logout deletes the session and clears the cookie", async () => { + await users.createUser("alice", "pw"); + const loginRes = await request(app) + .post("/api/session/login") + .send({ username: "alice", password: "pw" }); + const cookie = extractCookie(loginRes); + + const logout = await request(app).post("/api/session/logout").set("Cookie", cookie); + expect(logout.status).toBe(204); + + const me = await request(app).get("/api/session/me").set("Cookie", cookie); + expect(me.status).toBe(401); + }); + + it("POST /change-password requires old password and invalidates other sessions", async () => { + const u = await users.createUser("alice", "old"); + const cookieA = extractCookie( + await request(app).post("/api/session/login").send({ username: "alice", password: "old" }) + ); + const cookieB = extractCookie( + await request(app).post("/api/session/login").send({ username: "alice", password: "old" }) + ); + + const wrongOld = await request(app) + .post("/api/session/change-password") + .set("Cookie", cookieA) + .send({ oldPassword: "WRONG", newPassword: "new" }); + expect(wrongOld.status).toBe(401); + + const ok = await request(app) + .post("/api/session/change-password") + .set("Cookie", cookieA) + .send({ oldPassword: "old", newPassword: "new" }); + expect(ok.status).toBe(204); + + // Current session (cookieA) still valid + const meA = await request(app).get("/api/session/me").set("Cookie", cookieA); + expect(meA.status).toBe(200); + + // Other session (cookieB) invalidated + const meB = await request(app).get("/api/session/me").set("Cookie", cookieB); + expect(meB.status).toBe(401); + + expect(u.id).toBe(meA.body.id); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npx vitest run src/web/api/session.test.ts +``` +Expected: FAIL — `./session.js` not found. + +- [ ] **Step 3: Implement `src/web/api/session.ts`** + +```ts +import { Router } from "express"; +import type { Request, Response, NextFunction } from "express"; +import type { Logger } from "../../logger.js"; +import type { UserStore } from "../../data/users.js"; +import { UsernameTakenError } from "../../data/users.js"; +import type { SessionStore } from "../../data/sessions.js"; +import { SESSION_TTL_MS } from "../../data/sessions.js"; +import { SESSION_COOKIE_NAME, validateSessionFromHeaders } from "../auth/validateSession.js"; + +const FAILED_LOGIN_DELAY_MS = 250; + +function setSessionCookie(res: Response, token: string): void { + res.cookie(SESSION_COOKIE_NAME, token, { + httpOnly: true, + sameSite: "lax", + secure: res.req.secure, + path: "/", + maxAge: SESSION_TTL_MS, + }); +} + +function clearSessionCookie(res: Response): void { + res.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isValidUsername(v: unknown): v is string { + return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v); +} + +function isValidPassword(v: unknown): v is string { + return typeof v === "string" && v.length >= 8 && v.length <= 200; +} + +export function createSessionRouter( + users: UserStore, + sessions: SessionStore, + logger: Logger +): Router { + const router = Router(); + + const requireAuthInline = (req: Request, res: Response, next: NextFunction) => { + const result = validateSessionFromHeaders(req.headers.cookie, sessions); + if (!result) { + clearSessionCookie(res); + res.status(401).json({ error: "unauthenticated" }); + return; + } + req.user = { id: result.userId, username: result.username }; + next(); + }; + + router.get("/needs-setup", (_req, res) => { + res.json({ needsSetup: users.countUsers() === 0 }); + }); + + router.post("/setup", async (req, res) => { + const { username, password } = req.body ?? {}; + if (!isValidUsername(username) || !isValidPassword(password)) { + res.status(400).json({ error: "invalid username or password" }); + return; + } + if (users.countUsers() !== 0) { + res.status(409).json({ error: "already initialized" }); + return; + } + try { + const user = await users.createUser(username, password); + const { token } = sessions.createSession(user.id); + setSessionCookie(res, token); + logger.info({ userId: user.id, username }, "First admin created"); + res.json({ id: user.id, username: user.username }); + } catch (err) { + if (err instanceof UsernameTakenError) { + res.status(409).json({ error: "already initialized" }); + return; + } + logger.error({ err }, "setup failed"); + res.status(500).json({ error: "internal" }); + } + }); + + router.post("/login", async (req, res) => { + const { username, password } = req.body ?? {}; + if (typeof username !== "string" || typeof password !== "string") { + res.status(400).json({ error: "invalid request" }); + return; + } + const user = users.findByUsername(username); + const ok = user ? await users.verifyPassword(password, user.passwordHash) : false; + if (!user || !ok) { + await delay(FAILED_LOGIN_DELAY_MS); + res.status(401).json({ error: "invalid credentials" }); + return; + } + const { token } = sessions.createSession(user.id); + setSessionCookie(res, token); + res.json({ id: user.id, username: user.username }); + }); + + router.post("/logout", (req, res) => { + const cookieHeader = req.headers.cookie; + if (cookieHeader) { + const match = cookieHeader.split(";").map((p) => p.trim()).find((p) => p.startsWith(`${SESSION_COOKIE_NAME}=`)); + if (match) { + const token = decodeURIComponent(match.slice(SESSION_COOKIE_NAME.length + 1)); + sessions.deleteSession(token); + } + } + clearSessionCookie(res); + res.status(204).end(); + }); + + router.get("/me", requireAuthInline, (req, res) => { + res.json(req.user); + }); + + router.post("/change-password", requireAuthInline, async (req, res) => { + const { oldPassword, newPassword } = req.body ?? {}; + if (typeof oldPassword !== "string" || !isValidPassword(newPassword)) { + res.status(400).json({ error: "invalid request" }); + return; + } + const u = users.findById(req.user!.id); + if (!u || !(await users.verifyPassword(oldPassword, u.passwordHash))) { + await delay(FAILED_LOGIN_DELAY_MS); + res.status(401).json({ error: "invalid credentials" }); + return; + } + await users.changePassword(u.id, newPassword); + const currentToken = parseTokenFromCookie(req.headers.cookie); + sessions.deleteAllForUser(u.id, currentToken ?? undefined); + res.status(204).end(); + }); + + return router; +} + +function parseTokenFromCookie(cookieHeader: string | undefined): string | null { + if (!cookieHeader) return null; + const match = cookieHeader.split(";").map((p) => p.trim()).find((p) => p.startsWith(`${SESSION_COOKIE_NAME}=`)); + if (!match) return null; + return decodeURIComponent(match.slice(SESSION_COOKIE_NAME.length + 1)); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +npx vitest run src/web/api/session.test.ts +``` +Expected: all 8 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/api/session.ts src/web/api/session.test.ts +git commit -m "feat(auth): add /api/session router (setup, login, logout, me, change-password)" +``` + +--- + +## Task 9: Wire up `server.ts` (cookieParser, public/protected ordering, cleanup) + +**Files:** +- Modify: `src/web/server.ts` + +> The protected `/api/*` routers and the new public ones share the same path prefix. Express middleware ordering matters: register public routes BEFORE the `app.use("/api", csrfOriginCheck)` + `app.use("/api", requireAuth)` gates. + +- [ ] **Step 1: Replace the contents of `src/web/server.ts`** + +```ts +import express from "express"; +import http from "node:http"; +import path from "node:path"; +import cookieParser from "cookie-parser"; +import { WebSocketServer } from "ws"; +import type { BotManager } from "../bot/manager.js"; +import type { MusicProvider } from "../music/provider.js"; +import type { BotDatabase } from "../data/database.js"; +import type { BotConfig } from "../data/config.js"; +import type { Logger } from "../logger.js"; +import type { CookieStore } from "../music/auth.js"; +import type { AvatarStore } from "../data/avatars.js"; +import { createBotRouter } from "./api/bot.js"; +import { createMusicRouter } from "./api/music.js"; +import { createPlayerRouter } from "./api/player.js"; +import { createAuthRouter } from "./api/auth.js"; +import { createSessionRouter } from "./api/session.js"; +import { setupWebSocket } from "./websocket.js"; +import { createUserStore } from "../data/users.js"; +import { createSessionStore } from "../data/sessions.js"; +import { createRequireAuth } from "./middleware/requireAuth.js"; +import { csrfOriginCheck } from "./middleware/csrf.js"; +import { validateSessionFromHeaders } from "./auth/validateSession.js"; + +const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour + +export interface WebServerOptions { + port: number; + botManager: BotManager; + neteaseProvider: MusicProvider; + qqProvider: MusicProvider; + bilibiliProvider: MusicProvider; + database: BotDatabase; + config: BotConfig; + configPath: string; + logger: Logger; + cookieStore?: CookieStore; + avatarStore: AvatarStore; + staticDir?: string; +} + +export interface WebServer { + start(): Promise; + stop(): void; +} + +export function createWebServer(options: WebServerOptions): WebServer { + const app = express(); + const server = http.createServer(app); + const logger = options.logger.child({ component: "web" }); + + if (options.config.trustProxy) { + app.set("trust proxy", true); + } + + app.use(express.json({ limit: "400kb" })); + app.use(cookieParser()); + + const users = createUserStore(options.database.db); + const sessions = createSessionStore(options.database.db); + + // ─── Public routes (no auth, no CSRF) ─────────────────────────────────── + app.get("/api/health", (_req, res) => { + res.json({ status: "ok", version: "0.1.0" }); + }); + + app.get("/api/config/public-url", (_req, res) => { + const raw = (options.config.publicUrl ?? "").trim(); + res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null }); + }); + + app.use("/api/session", createSessionRouter(users, sessions, logger)); + + // ─── Gates for everything else under /api ─────────────────────────────── + const requireAuth = createRequireAuth(sessions); + app.use("/api", csrfOriginCheck); + app.use("/api", requireAuth); + + // ─── Protected routes ─────────────────────────────────────────────────── + app.use( + "/api/bot", + createBotRouter( + options.botManager, + options.config, + options.configPath, + logger, + options.database, + options.avatarStore, + ) + ); + app.use( + "/api/music", + createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger) + ); + app.use("/api/player", createPlayerRouter( + options.botManager, logger, options.database, + options.neteaseProvider, options.qqProvider, options.bilibiliProvider, + )); + app.use( + "/api/auth", + createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore) + ); + + // ─── Static SPA (public) ──────────────────────────────────────────────── + if (options.staticDir) { + app.use(express.static(options.staticDir)); + app.get(/^(?!\/api|\/ws)/, (_req, res) => { + res.sendFile(path.join(options.staticDir!, "index.html")); + }); + } + + server.on("error", (err) => { + logger.error({ err }, "HTTP server error"); + }); + + // ─── WebSocket with manual upgrade auth ──────────────────────────────── + const wss = new WebSocketServer({ noServer: true }); + wss.on("error", (err) => { + logger.error({ err }, "WebSocket server error"); + }); + server.on("upgrade", (req, socket, head) => { + if (req.url !== "/ws") { + socket.destroy(); + return; + } + const result = validateSessionFromHeaders(req.headers.cookie as string | undefined, sessions); + if (!result) { + 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 unknown as { userId: string }).userId = result.userId; + wss.emit("connection", ws, req); + }); + }); + const cleanupWs = setupWebSocket(wss, options.botManager, logger); + + // ─── Session cleanup interval ────────────────────────────────────────── + let cleanupTimer: ReturnType | null = null; + + return { + async start(): Promise { + return new Promise((resolve) => { + server.listen(options.port, () => { + logger.info({ port: options.port }, "Web server started"); + cleanupTimer = setInterval(() => { + try { + sessions.cleanupExpired(); + } catch (err) { + logger.error({ err }, "session cleanup failed"); + } + }, SESSION_CLEANUP_INTERVAL_MS); + resolve(); + }); + }); + }, + stop(): void { + if (cleanupTimer) { + clearInterval(cleanupTimer); + cleanupTimer = null; + } + cleanupWs(); + wss.close(); + server.close(); + }, + }; +} +``` + +- [ ] **Step 2: Type-check the project** + +```bash +npx tsc --noEmit +``` +Expected: zero errors. + +- [ ] **Step 3: Run the whole vitest suite** + +```bash +npx vitest run +``` +Expected: all existing + new tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/web/server.ts +git commit -m "feat(auth): gate /api/* behind requireAuth + csrf; gate /ws via upgrade handler" +``` + +--- + +## Task 10: WebSocket auth integration test + +**Files:** +- Create: `src/web/websocket-auth.test.ts` + +> Validates the end-to-end ws gate behavior. Uses a real HTTP server on a random port plus the `ws` client. + +- [ ] **Step 1: Write the failing test** + +Create `src/web/websocket-auth.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import express from "express"; +import http from "node:http"; +import { WebSocketServer, WebSocket as WSClient } from "ws"; +import { AddressInfo } from "node:net"; +import { createDatabase, type BotDatabase } from "../data/database.js"; +import { createUserStore } from "../data/users.js"; +import { createSessionStore } from "../data/sessions.js"; +import { validateSessionFromHeaders, SESSION_COOKIE_NAME } from "./auth/validateSession.js"; + +function buildServer(sessions: ReturnType) { + const app = express(); + const server = http.createServer(app); + const wss = new WebSocketServer({ noServer: true }); + wss.on("connection", (ws) => ws.send("hello")); + server.on("upgrade", (req, socket, head) => { + if (req.url !== "/ws") return socket.destroy(); + const r = validateSessionFromHeaders(req.headers.cookie as string | undefined, sessions); + if (!r) { + socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req)); + }); + return { server, wss }; +} + +describe("WebSocket auth at upgrade", () => { + let botDb: BotDatabase; + let httpServer: http.Server; + let port: number; + let validToken: string; + + beforeEach(async () => { + botDb = createDatabase(":memory:"); + const users = createUserStore(botDb.db); + const sessions = createSessionStore(botDb.db); + const u = await users.createUser("alice", "pw"); + validToken = sessions.createSession(u.id).token; + + const { server } = buildServer(sessions); + httpServer = server; + await new Promise((resolve) => httpServer.listen(0, resolve)); + port = (httpServer.address() as AddressInfo).port; + }); + + afterEach(async () => { + await new Promise((resolve) => httpServer.close(() => resolve())); + botDb.close(); + }); + + it("rejects upgrade without cookie (server-side close before open)", async () => { + const ws = new WSClient(`ws://127.0.0.1:${port}/ws`); + const result = await new Promise((resolve) => { + ws.on("open", () => resolve("opened")); + ws.on("unexpected-response", (_req, res) => resolve(`status:${res.statusCode}`)); + ws.on("error", () => resolve("error")); + }); + expect(result).toMatch(/^status:401$|^error$/); + }); + + it("accepts upgrade with a valid cookie", async () => { + const ws = new WSClient(`ws://127.0.0.1:${port}/ws`, { + headers: { Cookie: `${SESSION_COOKIE_NAME}=${validToken}` }, + }); + const msg = await new Promise((resolve, reject) => { + ws.on("message", (data) => resolve(data.toString())); + ws.on("error", reject); + }); + expect(msg).toBe("hello"); + ws.close(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it passes** + +```bash +npx vitest run src/web/websocket-auth.test.ts +``` +Expected: both tests PASS (the implementation already exists in `server.ts`; this test only validates it). + +- [ ] **Step 3: Commit** + +```bash +git add src/web/websocket-auth.test.ts +git commit -m "test(auth): verify ws upgrade gating end-to-end" +``` + +--- + +## Task 11: Frontend — `useSession` composable + +**Files:** +- Create: `web/src/composables/useSession.ts` + +> Single source of truth for auth state. Other code reads `currentUser`, `needsSetup`, calls `refresh()`, `login()`, `logout()`, `setup()`. + +- [ ] **Step 1: Create `web/src/composables/useSession.ts`** + +```ts +import { ref, computed, readonly } from "vue"; + +interface User { + id: string; + username: string; +} + +const currentUser = ref(null); +const needsSetup = ref(null); // null = unknown / not fetched yet +const ready = ref(false); + +async function refreshNeedsSetup(): Promise { + const res = await fetch("/api/session/needs-setup", { credentials: "same-origin" }); + if (res.ok) { + const body = await res.json(); + needsSetup.value = Boolean(body.needsSetup); + } +} + +async function refreshMe(): Promise { + const res = await fetch("/api/session/me", { credentials: "same-origin" }); + if (res.status === 200) { + currentUser.value = (await res.json()) as User; + } else { + currentUser.value = null; + } +} + +async function refresh(): Promise { + await refreshNeedsSetup(); + if (needsSetup.value) { + currentUser.value = null; + } else { + await refreshMe(); + } + ready.value = true; +} + +async function login(username: string, password: string): Promise { + const res = await fetch("/api/session/login", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? `login failed (${res.status})`); + } + currentUser.value = (await res.json()) as User; +} + +async function setup(username: string, password: string): Promise { + const res = await fetch("/api/session/setup", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? `setup failed (${res.status})`); + } + currentUser.value = (await res.json()) as User; + needsSetup.value = false; +} + +async function logout(): Promise { + await fetch("/api/session/logout", { method: "POST", credentials: "same-origin" }); + currentUser.value = null; +} + +export function useSession() { + return { + currentUser: readonly(currentUser), + needsSetup: readonly(needsSetup), + isAuthenticated: computed(() => currentUser.value !== null), + ready: readonly(ready), + refresh, + login, + logout, + setup, + }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add web/src/composables/useSession.ts +git commit -m "feat(web): add useSession composable" +``` + +--- + +## Task 12: Frontend — `Login.vue` + +**Files:** +- Create: `web/src/views/Login.vue` + +- [ ] **Step 1: Create `web/src/views/Login.vue`** + +```vue + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add web/src/views/Login.vue +git commit -m "feat(web): add Login view" +``` + +--- + +## Task 13: Frontend — `FirstRunSetup.vue` + router wiring + guard + +**Files:** +- Create: `web/src/views/FirstRunSetup.vue` +- Modify: `web/src/router/index.ts` + +- [ ] **Step 1: Create `web/src/views/FirstRunSetup.vue`** + +```vue + + + + + +``` + +- [ ] **Step 2: Replace `web/src/router/index.ts`** + +```ts +import { createRouter, createWebHistory } from 'vue-router'; +import { useSession } from '../composables/useSession.js'; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', name: 'home', component: () => import('../views/Home.vue') }, + { path: '/search', name: 'search', component: () => import('../views/Search.vue') }, + { path: '/library', name: 'library', component: () => import('../views/Library.vue') }, + { + path: '/playlist/:id', + name: 'playlist', + component: () => import('../views/Playlist.vue'), + meta: { kind: 'playlist' }, + }, + { + path: '/album/:id', + name: 'album', + component: () => import('../views/Playlist.vue'), + meta: { kind: 'album' }, + }, + { path: '/lyrics', name: 'lyrics', component: () => import('../views/Lyrics.vue') }, + { path: '/history', name: 'history', component: () => import('../views/History.vue') }, + { path: '/settings', name: 'settings', component: () => import('../views/Settings.vue') }, + { path: '/setup', name: 'setup', component: () => import('../views/Setup.vue') }, + { path: '/bot/:id', name: 'bot', component: () => import('../views/BotRedirect.vue') }, + + // Auth views + { path: '/login', name: 'login', component: () => import('../views/Login.vue'), meta: { public: true } }, + { path: '/first-run', name: 'first-run', component: () => import('../views/FirstRunSetup.vue'), meta: { public: true } }, + ], +}); + +const PUBLIC_NAMES = new Set(['login', 'first-run']); + +router.beforeEach(async (to) => { + const session = useSession(); + if (!session.ready.value) { + await session.refresh(); + } + + if (session.needsSetup.value && to.name !== 'first-run') { + return { name: 'first-run' }; + } + if (!session.needsSetup.value && to.name === 'first-run') { + return { name: 'home' }; + } + + if (PUBLIC_NAMES.has(to.name as string)) { + if (to.name === 'login' && session.isAuthenticated.value) { + return { name: 'home' }; + } + return true; + } + + if (!session.isAuthenticated.value) { + return { name: 'login', query: { next: to.fullPath } }; + } + return true; +}); + +export default router; +``` + +- [ ] **Step 3: Type-check the web project** + +```bash +cd web && npx vue-tsc --noEmit && cd .. +``` +Expected: zero errors. (If `vue-tsc` is not configured, use `npx tsc --noEmit` inside `web/`.) + +- [ ] **Step 4: Commit** + +```bash +git add web/src/views/FirstRunSetup.vue web/src/router/index.ts +git commit -m "feat(web): add /first-run + /login routes with auth guard" +``` + +--- + +## Task 14: Frontend — API client credentials + global 401 handler + +**Files:** +- Create: `web/src/api/http.ts` +- Modify: `web/src/App.vue` (call `installApiClient` once on mount) +- Audit: every `fetch(...)` call site in `web/src/` to add `credentials: 'same-origin'`. There are likely ~10-20. + +> Two-pronged fix: (1) one global `fetch` wrapper that intercepts 401 and redirects to login; (2) per-call `credentials: 'same-origin'` so cookies are sent. The wrapper sets credentials by default so most call sites only need to switch from `fetch` to `apiFetch`. + +- [ ] **Step 1: Create `web/src/api/http.ts`** + +```ts +import router from '../router/index.js'; +import { useSession } from '../composables/useSession.js'; + +let installed = false; + +/** + * Wraps fetch so every call: + * - sends cookies (`credentials: 'same-origin'`) + * - on 401 from /api/*: clear local session, redirect to /login + */ +export function apiFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise { + const merged: RequestInit = { + credentials: 'same-origin', + ...init, + headers: { ...(init.headers ?? {}) }, + }; + return fetch(input, merged).then(async (res) => { + if (res.status === 401 && isApiPath(input)) { + const session = useSession(); + await session.refresh(); + const current = router.currentRoute.value; + if (current.name !== 'login' && current.name !== 'first-run') { + await router.replace({ name: 'login', query: { next: current.fullPath } }); + } + } + return res; + }); +} + +function isApiPath(input: RequestInfo | URL): boolean { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + return url.startsWith('/api/'); +} + +/** + * Replaces window.fetch with apiFetch so existing call sites do not need to be touched. + * Call once at app startup. + */ +export function installApiClient(): void { + if (installed) return; + installed = true; + const original = window.fetch.bind(window); + window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + return apiFetch(input, init ?? {}); + }) as typeof window.fetch; + // Keep original accessible if anything needs to bypass + (window as unknown as { __originalFetch?: typeof fetch }).__originalFetch = original; +} +``` + +- [ ] **Step 2: Wire into `web/src/main.ts`** + +Replace `web/src/main.ts` with: + +```ts +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import App from './App.vue'; +import router from './router/index.js'; +import { installApiClient } from './api/http.js'; +import './styles/global.scss'; +import './styles/mobile.scss'; + +installApiClient(); + +const app = createApp(App); +app.use(createPinia()); +app.use(router); +app.mount('#app'); +``` + +- [ ] **Step 3: Type-check** + +```bash +cd web && npx vue-tsc --noEmit && cd .. +``` + +- [ ] **Step 4: Commit** + +```bash +git add web/src/api/http.ts web/src/main.ts +git commit -m "feat(web): install global fetch wrapper with credentials + 401 handling" +``` + +--- + +## Task 15: Frontend — logout + username chip in Navbar + +**Files:** +- Modify: `web/src/components/Navbar.vue` + +- [ ] **Step 1: Inspect current Navbar.vue** + +```bash +git show HEAD:web/src/components/Navbar.vue | head -80 +``` +Locate the right-hand side of the desktop nav (where Settings/menu actions live). + +- [ ] **Step 2: Add a user chip + logout button** + +Append (inside the existing template, right-most slot of the desktop nav — placement adjusted to match Navbar's existing structure): + +```vue + +``` + +In the existing ` + + diff --git a/web/src/views/Login.vue b/web/src/views/Login.vue new file mode 100644 index 0000000..4cad401 --- /dev/null +++ b/web/src/views/Login.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index 3f16861..863c267 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -21,6 +21,35 @@ + +
+

账户

+ +
+ + + + +
+

{{ ownPwError }}

+

{{ ownPwSuccess }}

+
+

机器人管理

@@ -458,6 +487,100 @@
+ + +
+

用户管理

+
+
+ + +
+
加载中…
+
{{ userLoadError }}
+
+ +
+ + + + +
+

{{ userMutationError }}

+ + +
+
+ + +
+ + +
+

{{ resetError }}

+
+ + +
+
+
+
+ + +
+

+ 操作审计 + +

+
{{ auditLoadError }}
+
暂无操作记录
+
+
+
{{ formatDateTime(e.timestamp) }}
+
{{ e.actorUsername ?? '—' }}
+
{{ describeAction(e) }}
+
+
+
@@ -469,6 +592,7 @@ import AvatarUpload from '../components/AvatarUpload.vue'; import CustomAvatarRow from '../components/CustomAvatarRow.vue'; import QRCode from 'qrcode'; import { usePlayerStore } from '../stores/player.js'; +import { useSession } from '../composables/useSession.js'; const store = usePlayerStore(); @@ -841,11 +965,242 @@ async function updateProfile(botId: string, key: keyof ProfileConfig, value: boo } } +// --- User Management --- +const session = useSession(); + +// --- Own password change (available to all authenticated users) --- +const ownPw = reactive({ old: '', new: '', confirm: '' }); +const ownPwError = ref(''); +const ownPwSuccess = ref(''); +const changingOwnPw = ref(false); + +async function onChangeOwnPassword() { + ownPwError.value = ''; + ownPwSuccess.value = ''; + if (ownPw.new !== ownPw.confirm) { + ownPwError.value = '两次输入的新密码不一致'; + return; + } + if (ownPw.new.length < 8) { + ownPwError.value = '新密码至少 8 位'; + return; + } + changingOwnPw.value = true; + try { + const res = await fetch('/api/session/change-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ oldPassword: ownPw.old, newPassword: ownPw.new }), + }); + if (!res.ok && res.status !== 204) { + const b = await res.json().catch(() => ({})); + throw new Error(b.error ?? `HTTP ${res.status}`); + } + ownPw.old = ''; + ownPw.new = ''; + ownPw.confirm = ''; + ownPwSuccess.value = '密码已更新'; + // The server kills other sessions but keeps the current one. No reload needed. + } catch (e) { + ownPwError.value = (e as Error).message; + } finally { + changingOwnPw.value = false; + } +} + +interface UserListEntry { id: string; username: string; createdAt: number; role: 'admin' | 'member' } +const userList = ref([]); +const userLoadError = ref(''); +const userMutationError = ref(''); +const newUser = reactive({ username: '', password: '', role: 'member' as 'admin' | 'member' }); +const creatingUser = ref(false); +const resetTarget = ref(null); +const resetPassword = ref(''); +const resetError = ref(''); +const resettingPw = ref(false); +const changingRoleId = ref(null); + +function isLastAdmin(u: UserListEntry): boolean { + if (u.role !== 'admin') return false; + const adminCount = userList.value.filter((x) => x.role === 'admin').length; + return adminCount <= 1; +} + +async function onToggleRole(u: UserListEntry) { + const newRole = u.role === 'admin' ? 'member' : 'admin'; + if (!confirm(`确认将 ${u.username} 切换为${newRole === 'admin' ? '管理员' : '成员'}?`)) return; + userMutationError.value = ''; + changingRoleId.value = u.id; + try { + const res = await fetch(`/api/users/${u.id}/role`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: newRole }), + }); + if (!res.ok && res.status !== 204) { + const b = await res.json().catch(() => ({})); + throw new Error(b.error ?? `HTTP ${res.status}`); + } + await loadUsers(); + } catch (e) { + userMutationError.value = (e as Error).message; + } finally { + changingRoleId.value = null; + } +} + +async function loadUsers() { + userLoadError.value = ''; + try { + const res = await fetch('/api/users'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + userList.value = body.users ?? []; + } catch (e) { + userLoadError.value = (e as Error).message; + } +} + +async function onCreateUser() { + userMutationError.value = ''; + creatingUser.value = true; + try { + const res = await fetch('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: newUser.username, password: newUser.password, role: newUser.role }), + }); + if (!res.ok) { + const b = await res.json().catch(() => ({})); + throw new Error(b.error ?? `HTTP ${res.status}`); + } + newUser.username = ''; + newUser.password = ''; + newUser.role = 'member'; + await loadUsers(); + } catch (e) { + userMutationError.value = (e as Error).message; + } finally { + creatingUser.value = false; + } +} + +async function onDeleteUser(u: UserListEntry) { + if (!confirm(`确认删除用户 ${u.username}?`)) return; + userMutationError.value = ''; + try { + const res = await fetch(`/api/users/${u.id}`, { method: 'DELETE' }); + if (!res.ok && res.status !== 204) { + const b = await res.json().catch(() => ({})); + throw new Error(b.error ?? `HTTP ${res.status}`); + } + await loadUsers(); + } catch (e) { + userMutationError.value = (e as Error).message; + } +} + +function openResetPassword(u: UserListEntry) { + resetTarget.value = u; + resetPassword.value = ''; + resetError.value = ''; +} + +async function onConfirmReset() { + if (!resetTarget.value) return; + if (resetPassword.value.length < 8) { + resetError.value = '密码至少 8 位'; + return; + } + resettingPw.value = true; + resetError.value = ''; + try { + const res = await fetch(`/api/users/${resetTarget.value.id}/reset-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ newPassword: resetPassword.value }), + }); + if (!res.ok && res.status !== 204) { + const b = await res.json().catch(() => ({})); + throw new Error(b.error ?? `HTTP ${res.status}`); + } + resetTarget.value = null; + } catch (e) { + resetError.value = (e as Error).message; + } finally { + resettingPw.value = false; + } +} + +function formatDate(ms: number): string { + const d = new Date(ms); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +// --- Audit Log --- +interface AuditEntry { + id: number; + timestamp: number; + actorId: string | null; + actorUsername: string | null; + targetUserId: string | null; + targetUsername: string | null; + action: string; +} + +const auditEntries = ref([]); +const auditLoadError = ref(''); +const auditLoading = ref(false); + +async function loadAudit() { + auditLoadError.value = ''; + auditLoading.value = true; + try { + const res = await fetch('/api/audit?limit=100'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + auditEntries.value = body.entries ?? []; + } catch (e) { + auditLoadError.value = (e as Error).message; + } finally { + auditLoading.value = false; + } +} + +function formatDateTime(ms: number): string { + const d = new Date(ms); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +function describeAction(e: AuditEntry): string { + const target = e.targetUsername ?? e.targetUserId ?? '—'; + switch (e.action) { + case 'admin.first_created': return `创建首位管理员 ${target}`; + case 'user.created': return `创建用户 ${target}`; + case 'user.deleted': return `删除用户 ${target}`; + case 'user.password_reset': return `重置 ${target} 的密码`; + case 'user.password_changed': return `修改自己的密码`; + case 'user.role_changed': return `变更 ${target} 的角色`; + default: return `${e.action} → ${target}`; + } +} + +function auditActionClass(action: string): string { + if (action === 'user.deleted') return 'audit-action-danger'; + if (action === 'user.password_reset' || action === 'user.password_changed') return 'audit-action-warn'; + return 'audit-action-ok'; +} + onMounted(() => { store.fetchBots(); // Refresh bot status on page visit checkAuthStatus(); loadQuality(); loadIdleTimeout(); + if (session.isAdmin.value) { + loadUsers(); + loadAudit(); + } }); onUnmounted(() => { @@ -1467,4 +1822,105 @@ onUnmounted(() => { } } } + +// --- User Management --- +.user-list { display: flex; flex-direction: column; gap: 8px; } +.user-item { + display: flex; align-items: center; justify-content: space-between; + padding: 12px; background: var(--bg-secondary); border-radius: var(--radius-sm); +} +.user-info { display: flex; flex-direction: column; gap: 4px; } +.user-name { font-weight: 500; color: var(--text-primary); display: flex; align-items: center; gap: 8px; } +.user-self-badge { + font-size: 11px; padding: 2px 6px; border-radius: 4px; + background: var(--color-primary); color: #fff; +} +.user-created { font-size: 12px; color: var(--text-secondary); } +.user-actions { display: flex; gap: 8px; } +.user-add-form { + display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; +} +.user-add-form .input { flex: 1; min-width: 140px; } +.user-empty, .user-error { font-size: 12px; color: var(--text-secondary); padding: 8px 0; } +.user-error { color: #e26a6a; } +.modal-hint { color: var(--text-secondary); font-size: 12px; margin: 0 0 8px; } +.form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; } + +.audit-refresh-btn { + margin-left: 10px; + border: 0; background: transparent; + color: var(--text-secondary); cursor: pointer; + display: inline-flex; align-items: center; + font-size: 16px; + &:hover { color: var(--text-primary); } + &:disabled { opacity: 0.5; cursor: progress; } +} +.spinning { animation: spin 1s linear infinite; } +@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } + +.audit-list { + display: flex; flex-direction: column; + border-radius: var(--radius-sm); + background: var(--bg-secondary); + max-height: 480px; + overflow-y: auto; +} +.audit-row { + display: grid; + grid-template-columns: 170px 120px 1fr; + gap: 12px; + padding: 10px 12px; + border-bottom: 1px solid var(--border-color); + font-size: 13px; + &:last-child { border-bottom: 0; } +} +.audit-time { + color: var(--text-secondary); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + font-size: 12px; + white-space: nowrap; +} +.audit-actor { + color: var(--text-primary); + font-weight: 500; +} +.audit-action { color: var(--text-primary); } +.audit-action-ok { color: var(--text-primary); } +.audit-action-warn { color: #d3a44b; } +.audit-action-danger { color: #e26a6a; } + +@media (max-width: 640px) { + .audit-row { + grid-template-columns: 1fr; + gap: 4px; + } +} + +.user-role-badge { + font-size: 11px; padding: 2px 6px; border-radius: 4px; margin-left: 6px; + font-weight: 500; +} +.role-admin { background: rgba(99, 145, 226, 0.18); color: #6391e2; } +.role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); } +.user-role-select { flex: 0 0 110px; } + +// --- Account section (own password change) --- +.account-info-card { + display: flex; flex-direction: column; gap: 8px; + padding: 12px; background: var(--bg-secondary); border-radius: var(--radius-sm); + margin-bottom: 12px; +} +.account-row { + display: flex; justify-content: space-between; align-items: center; + font-size: 13px; +} +.account-label { color: var(--text-secondary); } +.account-value { color: var(--text-primary); font-weight: 500; } +.change-pw-form { + display: flex; flex-direction: column; gap: 8px; + max-width: 360px; +} +.change-pw-form .input { width: 100%; } +.change-pw-form button { align-self: flex-start; } +.user-success { color: #4caf7a; font-size: 13px; margin: 4px 0 0; }