diff --git a/README.md b/README.md index 99db724..2ff07f0 100644 --- a/README.md +++ b/README.md @@ -462,6 +462,52 @@ sudo systemctl start tsmusicbot 聊天命令、WebUI、REST API 三种入口的改动都会被持久化。播放队列、当前歌曲、进度、`!fm` / `!artist` 等临时播放状态仍为一次性状态,重启后不保留(`!fm` / `!artist` 内部临时切换的随机 / 循环也**不会**覆盖你用 `!mode` 显式保存的偏好)。 +## REST API(API Key) + +除浏览器 session 登录外,REST API 还支持用 **API Key** 调用,便于脚本、Home Assistant 等外部集成。 + +### 创建 Key + +登录 WebUI → 设置页 → 「API 密钥」→ 输入名称 → 生成。明文**只在创建时显示一次**(形如 `tsmb_xxxxx…`),之后只能看到前缀;可随时在设置页吊销。Key 的权限与所属账户一致:管理员拥有全部权限,成员只能操作被授权的机器人、使用被授予的能力(播放控制 / 队列管理等)。每位用户最多创建 20 个 Key。 + +### 调用方式 + +两种请求头任选其一: + +``` +Authorization: Bearer tsmb_xxxxxxxxxxxx +X-API-Key: tsmb_xxxxxxxxxxxx +``` + +> 修改类请求(POST/PUT/DELETE)无需 CSRF Origin 头;WebSocket 推送(`/ws`)暂不支持 API Key,仅限浏览器 session。 + +### 常用端点示例 + +```bash +# 机器人列表(拿到 botId) +curl -H "Authorization: Bearer $KEY" http://127.0.0.1:3000/api/bot + +# 当前队列 + 播放状态 +curl -H "Authorization: Bearer $KEY" http://127.0.0.1:3000/api/player//queue + +# 点歌(搜索文本 + 平台:netease/qq/bilibili/youtube/kugou/jellyfin/local) +curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"query":"周杰伦 晴天","platform":"netease"}' \ + http://127.0.0.1:3000/api/player//play + +# 搜索歌曲(拿 song id / song 对象) +curl -H "Authorization: Bearer $KEY" \ + "http://127.0.0.1:3000/api/music/search?q=晴天&platform=netease" + +# 播放控制 +curl -X POST -H "X-API-Key: $KEY" http://127.0.0.1:3000/api/player//pause +curl -X POST -H "X-API-Key: $KEY" http://127.0.0.1:3000/api/player//next +curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ + -d '{"volume":50}' http://127.0.0.1:3000/api/player//volume +``` + +全部端点、参数与返回值见 **[docs/API.md](docs/API.md)**。认证失败返回 `401 {"error":"invalid api key"}`,越权返回 `403`。 + ## 项目架构 ``` diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..331db3f --- /dev/null +++ b/docs/API.md @@ -0,0 +1,365 @@ +# REST API 参考 + +本文档列出机器人对外提供的全部 REST API 端点、参数与返回。所有端点均支持两种认证方式(见下),除单独标注「仅浏览器 session」的端点外。 + +## 通用约定 + +### 认证 + +``` +Authorization: Bearer tsmb_xxxxxxxxxxxx +# 或 +X-API-Key: tsmb_xxxxxxxxxxxx +``` + +Key 在 WebUI 设置页创建,权限与所属账户一致。浏览器 session(cookie)也可调用全部端点,两者行为相同。 + +### 错误格式 + +所有错误返回统一为 JSON `{ "error": "..." }`: + +| 状态码 | 含义 | +|--------|------| +| 400 | 参数缺失或格式错误 | +| 401 | 未认证 / API Key 无效(`invalid api key`) | +| 403 | 无权限(能力不足、机器人未授权、API Key 试图管理 Key 等) | +| 404 | 资源不存在 | +| 409 | 冲突(如收藏已存在、Key 数量达上限) | +| 500 | 服务器内部错误 | + +### 权限模型 + +| 标注 | 含义 | +|------|------| +| 公开 | 无需认证 | +| 已认证 | 任意登录用户 / 有效 API Key | +| 非游客 | API Key 用户恒满足(guest session 除外) | +| `player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality` | 需要账户持有对应能力;管理员恒通过 | +| 机器人访问 | 成员只能操作被授予的机器人(账户权限中的 bot 范围),管理员不限 | +| 管理员 | 仅 `role=admin` | + +### 平台(platform)取值 + +`netease` / `qq` / `bilibili` / `youtube` / `kugou` / `jellyfin` / `local` / `spotify` + +省略 `platform` 时使用设置页配置的默认音源;已禁用的音源返回 `400 音源未启用`。 + +### 点歌归属 + +`/play`、`/add`、`/play-*`、`/add-*` 等入队端点会把 `requestedBy` 记为 Key 所属账户的用户名,队列与播放历史中可见。 + +--- + +## 数据模型 + +```ts +// 歌曲(搜索结果 / 队列元素) +interface Song { + id: string; // 平台内歌曲 id + name: string; + artist: string; + album: string; + duration: number; // 秒 + coverUrl: string; + platform: Platform; + vip?: boolean; // VIP/版权受限(仅试听) +} + +// 队列中的歌曲(Song + 归属;url 仅播放时内部解析,不出现在响应里) +interface QueuedSong extends Omit { + requestedBy?: string; +} + +interface Album { id: string; name: string; artist: string; coverUrl: string; songCount: number; platform: Platform } +interface Playlist { id: string; name: string; coverUrl: string; songCount: number; platform: Platform } + +// 机器人实时状态 +interface BotStatus { + id: string; + name: string; + connected: boolean; + playing: boolean; + paused: boolean; + currentSong: QueuedSong | null; + queueSize: number; + volume: number; // 0-100 + playMode: "seq" | "loop" | "random" | "rloop"; + elapsed: number; // 当前曲目已播秒数 + effectiveDuration?: number; // 当前曲实际播放时长(试听片段=试听秒数) +} +``` + +--- + +## 公开端点(无需认证) + +### GET /api/health + +```json +{ "status": "ok", "version": "0.1.0" } +``` + +### GET /api/config/public-url + +```json +{ "publicUrl": "https://bot.example.com" } // 未配置时为 null +``` + +--- + +## 机器人管理 /api/bot + +| 方法 | 路径 | 权限 | 说明 | +|------|------|------|------| +| GET | `/api/bot` | 已认证 | 机器人列表(成员只返回被授权的) | +| GET | `/api/bot/settings` | 非游客 | 全局行为设置 | +| POST | `/api/bot/settings` | `bot.manage` | 保存全局设置(部分合并) | +| POST | `/api/bot` | `bot.manage` | 创建机器人 | +| GET | `/api/bot/:id` | 机器人访问 | 单个机器人状态 | +| PUT | `/api/bot/:id` | `bot.manage` + 机器人访问 | 更新连接配置 | +| DELETE | `/api/bot/:id` | `bot.manage` + 机器人访问 | 删除机器人 | +| POST | `/api/bot/:id/start` | `bot.manage` + 机器人访问 | 连接服务器 | +| POST | `/api/bot/:id/stop` | `bot.manage` + 机器人访问 | 断开连接 | +| GET | `/api/bot/:id/config` | `bot.manage` + 机器人访问 | 保存的连接配置(不含 identity/TS6 key) | +| GET / PUT / DELETE | `/api/bot/:id/avatar` | `bot.manage` + 机器人访问 | 自定义头像 | + +### GET /api/bot + +```json +{ "bots": [ { "id": "…", "name": "客厅bot", "connected": true, "playing": true, "paused": false, + "currentSong": { "…": "QueuedSong" }, "queueSize": 3, "volume": 75, + "playMode": "seq", "elapsed": 42.5, "effectiveDuration": 269 } ] } +``` + +### POST /api/bot + +```json +// 请求体(name、serverAddress、nickname 必填;serverPort 默认 9987) +{ "name": "客厅bot", "serverAddress": "ts.example.com", "serverPort": 9987, + "nickname": "♪ 音乐机器人", "defaultChannel": "音乐频道", "channelId": "12", + "channelPassword": "", "serverPassword": "", "autoStart": true } +// 201 返回 BotStatus +``` + +### PUT /api/bot/:id + +请求体字段同上(全部可选),返回 `{ "success": true }`。连接相关修改需重启机器人(`stop` 后 `start`)生效。 + +### POST /api/bot/settings(部分合并,未传的字段不变) + +```json +{ + "idleTimeoutMinutes": 30, // 空闲自动断开,0=不启用 + "autoPauseOnEmpty": true, // 频道无人自动暂停 + "localAudioEnabled": true, // 本地音频 + "voiceDucking": { "enabled": true, "volumePercent": 20 }, + "savedQueuesEnabled": true, + "playKeepsQueue": false, // !play 是否保留队列 + "adminGroups": [6], + "enabledProviders": ["netease","qq","bilibili","youtube","kugou"], + "defaultPlatform": "netease", // null/"" 清除 + "guestMode": { "enabled": false, "bots": "all", "permissions": { "…": true } }, + "spotify": { "enabled": false, "clientId": "…", "clientSecret": "…", "backend": "auto", "bitrate": 160, "deviceName": "…" }, + "jellyfin": { "serverUrl": "…", "authMode": "userpass", "username": "…", "password": "…" } +} +// 返回:与 GET /settings 相同结构(spotify.clientSecret / jellyfin.password 永不回传,仅 hasClientSecret / hasPassword 布尔) +``` + +### PUT /api/bot/:id/avatar + +请求体 `{ "dataUrl": "data:image/png;base64,…" }`(png/jpeg/webp,≤200KB),返回 `{ "path": "avatars/xx.png" }`。 + +--- + +## 播放控制 /api/player/:botId + +以下所有端点都要求机器人访问权限;标注能力的管理类操作还需对应能力。`{ "message": "…" }` 为命令执行回执文本(与聊天命令回执一致),失败时 message 中带原因或返回 4xx/5xx。 + +### 播放入口 + +| 方法 | 路径 | 能力 | 请求体 | 返回 | +|------|------|------|--------|------| +| POST | `/play` | `player.control` | `{ query, platform? }`(搜索文本) | `{ message }` | +| POST | `/add` | `player.queue` | `{ query, platform? }` | `{ message }` | +| POST | `/play-song` | `player.control` | `{ song }`(Song 对象,清空队列播放) | `{ ok, message }` | +| POST | `/play-now-song` | `player.control` | `{ song }`(插入当前曲后立即播放,保留队列) | `{ ok, message }` | +| POST | `/play-next-song` | `player.control` | `{ song }`(插播下一首;空闲时直接播放) | `{ ok, message }` | +| POST | `/add-song` | `player.queue` | `{ song }`(入队;空闲时立即播放) | `{ message }` | +| POST | `/add-by-id` | `player.queue` | `{ songId, platform? }` | `{ message }` | +| POST | `/play-playlist` | `player.control` | `{ playlistId, platform? }`(清队列载入歌单) | `{ ok, message }` | +| POST | `/play-album` | `player.control` | `{ albumId, platform? }`(清队列载入专辑) | `{ ok, message }` | +| POST | `/playlist` | `player.queue` | `{ playlistId, platform? }`(追加整个歌单) | `{ message }` | +| POST | `/fm` | `player.control` | `{ platform? }`(私人 FM 模式) | `{ ok, message }` | + +`/play` 与 `/add` 接受搜索文本,内部按 `platform` 调对应音源搜索并播放/入队第一个结果;`/play-song` 系列接受 `/api/music` 返回的完整 Song 对象。B站多P视频的 Song `id` 形如 `BVxxxx?p=2`(见 `/api/music/bilibili/parts`),传对应分P的 id 即播放该分P。 + +`/fm` 的平台为网易时,若调用者账户已绑定个人网易账号(见 `/api/me/music`),FM 曲目按**个人账号**的口味推荐;未绑定则使用机器人共享登录。 + +### 播放器控制 + +| 方法 | 路径 | 能力 | 请求体 | 返回 | +|------|------|------|--------|------| +| POST | `/pause` | `player.control` | — | `{ message }` | +| POST | `/resume` | `player.control` | — | `{ message }` | +| POST | `/next` | `player.control` | — | `{ message }` | +| POST | `/prev` | `player.control` | — | `{ message }` | +| POST | `/stop` | `player.control` | — | `{ message }` | +| POST | `/clear` | `player.queue` | — | `{ message }` | +| POST | `/volume` | `player.control` | `{ volume: 0-100 }` | `{ message }` | +| POST | `/mode` | `player.control` | `{ mode: "seq"|"loop"|"random"|"rloop" }` | `{ message }` | +| POST | `/seek` | `player.control` | `{ position: 秒 }` | `{ message, seekOffset }` | +| POST | `/play-at` | `player.control` | `{ index: 队列下标 }` | `{ message }`,越界 400 | + +### 状态与队列 + +| 方法 | 路径 | 返回 | +|------|------|------| +| GET | `/queue` | `{ queue: QueuedSong[], status: BotStatus }` | +| GET | `/elapsed` | `{ elapsed: 42.5 }` | +| DELETE | `/queue/:index` | `{ message }`(移除指定下标,能力 `player.queue`) | +| GET | `/history?limit=50` | `{ history: [{ id, name, artist, album, coverUrl, platform, playedAt, requestedBy }] }` | +| GET | `/profile` | ProfileConfig | +| PUT | `/profile` | ProfileConfig(能力 `bot.manage`) | + +ProfileConfig:`{ avatarEnabled, descriptionEnabled, nicknameEnabled, awayStatusEnabled, channelDescEnabled, nowPlayingMsgEnabled }`(机器人头像/昵称/频道描述等自动更新开关)。 + +--- + +## 音乐数据 /api/music + +除特别标注外均为「已认证」;`platform` 为可选 query 参数。 + +| 方法 | 路径 | 参数 | 返回 | +|------|------|------|------| +| GET | `/search` | `q`(必填)、`platform`、`limit`(默认 20)、`offset`(默认 0) | `{ songs, albums, playlists }` | +| GET | `/search/all` | `q`(必填)、`limit` | 各音源合并的 `{ songs, albums, playlists }`(不含 spotify) | +| GET | `/song/:id` | `platform` | Song 对象,无则 404 | +| GET | `/album/:id` | `platform` | `{ songs: Song[] }` | +| GET | `/playlist/:id` | `platform` | `{ songs: Song[] }` | +| GET | `/playlist/:id/detail` | `platform` | `{ playlist: { id, name, description, coverUrl, songCount } }`(音源不支持时 501) | +| GET | `/lyrics/:id` | `platform` | `{ lyrics }` | +| GET | `/recommend/playlists` | `platform` | `{ playlists }` | +| GET | `/recommend/songs` | `platform` | `{ songs }`(每日推荐;非游客) | +| GET | `/personal/fm` | `platform` | `{ songs }`(私人 FM;非游客) | +| GET | `/user/playlists` | `platform` | `{ playlists }`(当前登录音源账号的歌单;非游客) | +| GET | `/bilibili/popular` | `limit`(默认 20) | `{ songs }` | +| GET | `/bilibili/parts` | `bvid`(BV 号或视频链接) | `{ bvid, title, coverUrl, artist, parts }`(无此视频 404) | +| GET | `/providers` | — | `{ enabled: Platform[], default: Platform }` | +| GET | `/quality` | — | `{ netease, qq, bilibili, local, kugou, spotify, jellyfin }` | +| POST | `/quality` | `{ quality, platform? }`(能力 `quality`;省略 platform 时对所有音源生效) | `{ success, quality }` | + +### Jellyfin 音乐库 + +| 方法 | 路径 | 参数 | 返回 | +|------|------|------|------| +| GET | `/jellyfin/latest-albums` | `limit`(默认 12) | `{ albums }` | +| GET | `/jellyfin/most-played` | `limit`(默认 12) | `{ songs }` | +| GET | `/jellyfin/favorites` | `limit`(默认 100) | `{ songs }`(非游客) | +| GET | `/jellyfin/genres` | `limit`(默认 30) | `{ genres: [{ id, name }] }` | +| GET | `/jellyfin/genre/:id/songs` | `limit`(默认 100) | `{ songs }` | + +### 本地音频上传 + +`POST /api/music/local/upload` — 能力 `player.queue`。请求体为**原始音频文件**(audio/* 或 video/*,≤500MB,非 multipart;文件名放 `x-filename` 请求头)。返回 `{ song }`;本地音频关闭时 403。 + +```bash +curl -X POST -H "X-API-Key: $KEY" -H "x-filename: theme.mp3" \ + --data-binary @theme.mp3 http://127.0.0.1:3000/api/music/local/upload +``` + +--- + +## 收藏 /api/favorites(非游客,仅本人数据) + +| 方法 | 路径 | 参数 | 返回 | +|------|------|------|------| +| GET | `/` | — | `{ favorites: [{ id, platform, playlistId, name, coverUrl, songCount, createdAt }] }` | +| POST | `/` | `{ platform, playlistId, name, coverUrl?, songCount? }` | `{ success: true }`;已收藏 409 | +| GET | `/check` | `platform`、`playlistId` | `{ favorited: bool }` | +| DELETE | `/:id` | 收藏记录 id | `{ success: true }` | + +--- + +## 保存的队列 /api/saved-queues(非游客;需在设置页开启「保存队列」) + +所有权:私有为本人,`shared: true` 保存到共享桶;列表返回本人的+共享的;他人私有队列 404。 + +| 方法 | 路径 | 参数 | 返回 | +|------|------|------|------| +| GET | `/` | — | `{ queues: [{ id, ownerId, name, songCount, createdAt, updatedAt }] }` | +| POST | `/` | `{ botId, name, shared? }`(快照该 bot 当前队列,同名覆盖) | `{ queue }`;队列空 400 | +| POST | `/:id/load` | `{ botId, mode?: "replace"(默认)|"append" }` | `{ ok, loaded, mode }` | +| DELETE | `/:id` | — | `{ ok: true }` | + +--- + +## 平台账号 /api/auth + +| 方法 | 路径 | 权限 | 参数 | 返回 | +|------|------|------|------|------| +| GET | `/status` | 非游客 | `platform` | `{ platform, loggedIn, nickname?, avatarUrl? }` | +| POST | `/qrcode` | `platform.auth` | `{ platform }`(netease/qq/bilibili/kugou) | `{ qrUrl, qrImg?(base64 data URL), key }` | +| GET | `/qrcode/status` | 非游客 | `key`、`platform` | `{ status: "waiting"|"scanned"|"confirmed"|"expired" }`;confirmed 自动持久化登录态 | +| POST | `/jellyfin/test` | `platform.auth` | `{ serverUrl?, authMode?, username?, password?, apiKey?, userId? }`(空字段回退已存配置) | `{ ok, serverName?, version?, error? }` | +| POST | `/sms/send` | `platform.auth` | `{ phone }`(网易手机号登录) | `{ success }` | +| POST | `/sms/verify` | `platform.auth` | `{ phone, code }` | `{ success }` | +| POST | `/cookie` | `platform.auth` | `{ platform, cookie }`(不支持 youtube/jellyfin) | `{ success: true }` | + +## Spotify /api/spotify(配置 Spotify OAuth 后挂载) + +| 方法 | 路径 | 权限 | 返回 | +|------|------|------|------| +| GET | `/login` | `platform.auth` | `{ url }`(accounts.spotify.com 授权页,浏览器打开) | +| GET | `/callback` | — | OAuth 回调,重定向回 WebUI(浏览器流程,脚本无需调用) | +| GET | `/status` | 非游客 | `{ authorized, backend, deviceName, binaryAvailable }` | + +--- + +## 个人音乐账号 /api/me/music(非游客,仅本人数据) + +绑定**自己的**网易账号,让 `POST /api/player/:botId/fm` 按个人口味推荐;cookie 只存服务端,任何接口都不会回传。与 `/api/auth` 的机器人共享登录互不影响。 + +| 方法 | 路径 | 参数 | 返回 | +|------|------|------|------| +| GET | `/netease/status` | — | `{ linked, loggedIn, nickname?, avatarUrl? }` | +| POST | `/netease/qrcode` | — | `{ qrUrl, qrImg?(base64 data URL), key }`(个人绑定专用二维码) | +| GET | `/netease/qrcode/status` | `key` | `{ status: "waiting"|"scanned"|"confirmed"|"expired" }`;confirmed 后自动绑定到当前账户 | +| DELETE | `/netease` | — | `{ ok: true }`(解除绑定) | + +--- + +## API 密钥管理 /api/keys(仅浏览器 session) + +API Key **不能**调用这些端点(403)——泄露的 Key 无法自我复制;游客 session 也被拒绝。浏览器登录后调用。 + +| 方法 | 路径 | 参数 | 返回 | +|------|------|------|------| +| GET | `/` | `?all=1`(管理员可看全部,含 username) | `{ keys: [{ id, userId, username?, name, keyPrefix, createdAt, lastUsedAt }] }` | +| POST | `/` | `{ name: "1-64字符" }` | `201 { key: {...}, rawKey: "tsmb_…" }`(明文仅此一次);达上限 409 | +| DELETE | `/:id` | — | `{ success: true }`(仅本人;管理员可删任意) | + +--- + +## 用户管理 /api/users(管理员) + +| 方法 | 路径 | 参数 | 返回 | +|------|------|------|------| +| GET | `/` | — | `{ users: [{ id, username, createdAt, role }] }` | +| POST | `/` | `{ username, password(≥8位), role: "admin"|"member" }` | `201 { id, username, role }`;重名 409 | +| DELETE | `/:id` | — | `204`(级联删除其 session 与 API Key) | +| POST | `/:id/reset-password` | `{ newPassword }` | `204`(该用户的 session 与 API Key 全部失效) | +| PATCH | `/:id/role` | `{ role: "admin"|"member" }` | `204`(不能降级最后一个管理员) | +| GET | `/:id/permissions` | — | `{ capabilities: string[], bots: "all" | string[] }` | +| PUT | `/:id/permissions` | `{ capabilities, bots: "all"|string[] }` | `{ success: true }` | + +## 操作审计 /api/audit(管理员) + +| 方法 | 路径 | 参数 | 返回 | +|------|------|------|------| +| GET | `/` | `limit`(1-500,默认 100)、`offset`(默认 0) | `{ entries: [{ id, timestamp, actorId, actorUsername, targetUserId, targetUsername, action }] }` | + +action 取值:`admin.first_created`、`user.created`、`user.deleted`、`user.password_reset`、`user.password_changed`、`user.role_changed`、`user.permissions_changed`、`api_key.created`、`api_key.deleted`。 + +## 会话 /api/session(仅浏览器,API Key 不可用) + +会话登录本身无法用 API Key 完成:`GET /needs-setup`、`POST /setup`、`POST /login`、`POST /guest`、`POST /logout`、`GET /me`、`POST /change-password` 均基于 cookie。`/login` 有每 IP 每分钟 5 次、`/setup` 3 次的限流。 diff --git a/src/data/api-keys.test.ts b/src/data/api-keys.test.ts new file mode 100644 index 0000000..4000e54 --- /dev/null +++ b/src/data/api-keys.test.ts @@ -0,0 +1,124 @@ +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 { + createApiKeyStore, + type ApiKeyStore, + MAX_API_KEYS_PER_USER, + API_KEY_TOUCH_INTERVAL_MS, +} from "./api-keys.js"; + +function sha256(key: string) { + return createHash("sha256").update(key).digest("hex"); +} + +describe("ApiKeyStore", () => { + let botDb: BotDatabase; + let users: UserStore; + let keys: ApiKeyStore; + let userId: string; + + beforeEach(async () => { + botDb = createDatabase(":memory:"); + users = createUserStore(botDb.db); + keys = createApiKeyStore(botDb.db); + const u = await users.createUser("alice", "pw-alice", "admin"); + userId = u.id; + }); + + afterEach(() => { + vi.useRealTimers(); + botDb.close(); + }); + + it("create returns a tsmb_-prefixed raw key whose sha256 is stored, never the plaintext", () => { + const created = keys.create(userId, "ci"); + expect(created).not.toBeNull(); + expect(created!.rawKey).toMatch(/^tsmb_[A-Za-z0-9_-]{40,}$/); + const row = botDb.db.prepare("SELECT keyHash, keyPrefix FROM api_keys").get() as { + keyHash: string; + keyPrefix: string; + }; + expect(row.keyHash).toBe(sha256(created!.rawKey)); + expect(row.keyHash).not.toContain(created!.rawKey); + expect(created!.key.keyPrefix).toBe(created!.rawKey.slice(0, 12)); + }); + + it("validateAndTouch resolves the owner user for a fresh key", () => { + const { rawKey } = keys.create(userId, "ci")!; + const result = keys.validateAndTouch(rawKey); + expect(result).not.toBeNull(); + expect(result!.userId).toBe(userId); + expect(result!.username).toBe("alice"); + expect(result!.role).toBe("admin"); + }); + + it("validateAndTouch returns null for an unknown or empty key", () => { + keys.create(userId, "ci"); + expect(keys.validateAndTouch("tsmb_not-a-real-key")).toBeNull(); + expect(keys.validateAndTouch("")).toBeNull(); + }); + + it("delete removes the key so it no longer validates", () => { + const { key, rawKey } = keys.create(userId, "ci")!; + expect(keys.delete(key.id, userId)).toBe(true); + expect(keys.validateAndTouch(rawKey)).toBeNull(); + }); + + it("delete with userId refuses to remove another user's key", async () => { + const { key } = keys.create(userId, "ci")!; + const other = await users.createUser("bob", "pw-bob", "member"); + expect(keys.delete(key.id, other.id)).toBe(false); + expect(keys.delete(key.id)).toBe(true); + }); + + it("keys of a deleted user stop validating", async () => { + const { rawKey } = keys.create(userId, "ci")!; + users.deleteUser(userId); + expect(keys.validateAndTouch(rawKey)).toBeNull(); + }); + + it("enforces the per-user key cap", () => { + for (let i = 0; i < MAX_API_KEYS_PER_USER; i++) { + expect(keys.create(userId, `key-${i}`)).not.toBeNull(); + } + expect(keys.create(userId, "one-too-many")).toBeNull(); + expect(keys.listForUser(userId)).toHaveLength(MAX_API_KEYS_PER_USER); + }); + + it("touches lastUsedAt at most once per interval", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + const { rawKey } = keys.create(userId, "ci")!; + keys.validateAndTouch(rawKey); + const first = (botDb.db.prepare("SELECT lastUsedAt FROM api_keys").get() as { lastUsedAt: number }).lastUsedAt; + vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + 30_000); + keys.validateAndTouch(rawKey); + const second = (botDb.db.prepare("SELECT lastUsedAt FROM api_keys").get() as { lastUsedAt: number }).lastUsedAt; + expect(second).toBe(first); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + API_KEY_TOUCH_INTERVAL_MS + 1000); + keys.validateAndTouch(rawKey); + const third = (botDb.db.prepare("SELECT lastUsedAt FROM api_keys").get() as { lastUsedAt: number }).lastUsedAt; + expect(third).toBeGreaterThan(first); + }); + + it("deleteAllForUser clears every key of that user", async () => { + keys.create(userId, "a"); + keys.create(userId, "b"); + const other = await users.createUser("bob", "pw-bob", "member"); + keys.create(other.id, "c"); + keys.deleteAllForUser(userId); + expect(keys.listForUser(userId)).toHaveLength(0); + expect(keys.listForUser(other.id)).toHaveLength(1); + }); + + it("listAll exposes usernames for admin views", async () => { + keys.create(userId, "ci"); + const other = await users.createUser("bob", "pw-bob", "member"); + keys.create(other.id, "deploy"); + const all = keys.listAll(); + expect(all).toHaveLength(2); + expect(all.map((k) => k.username).sort()).toEqual(["alice", "bob"]); + }); +}); diff --git a/src/data/api-keys.ts b/src/data/api-keys.ts new file mode 100644 index 0000000..a203a11 --- /dev/null +++ b/src/data/api-keys.ts @@ -0,0 +1,130 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import type Database from "better-sqlite3"; + +export const MAX_API_KEYS_PER_USER = 20; +export const API_KEY_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour +/** Visible prefix stored for list views, e.g. "tsmb_a1b2c3d4". */ +export const API_KEY_PREFIX_LENGTH = 12; + +export interface ApiKeyRow { + id: string; + userId: string; + name: string; + keyPrefix: string; + createdAt: number; + lastUsedAt: number | null; +} + +export interface ApiKeyWithUser extends ApiKeyRow { + username: string; +} + +export interface ApiKeyValidation { + keyId: string; + userId: string; + username: string; + role: "admin" | "member"; +} + +export interface CreatedApiKey { + key: ApiKeyRow; + /** Plaintext key — returned exactly once, at creation time. */ + rawKey: string; +} + +export interface ApiKeyStore { + /** Returns null when the per-user key cap is reached. */ + create(userId: string, name: string): CreatedApiKey | null; + listForUser(userId: string): ApiKeyRow[]; + listAll(): ApiKeyWithUser[]; + /** With userId, only deletes a key owned by that user. */ + delete(id: string, userId?: string): boolean; + deleteAllForUser(userId: string): void; + validateAndTouch(rawKey: string): ApiKeyValidation | null; +} + +function hashKey(rawKey: string): string { + return createHash("sha256").update(rawKey).digest("hex"); +} + +export function createApiKeyStore(db: Database.Database): ApiKeyStore { + const insertStmt = db.prepare( + "INSERT INTO api_keys (id, userId, name, keyHash, keyPrefix, createdAt, lastUsedAt) VALUES (?, ?, ?, ?, ?, ?, NULL)" + ); + const selectForUserStmt = db.prepare( + "SELECT id, userId, name, keyPrefix, createdAt, lastUsedAt FROM api_keys WHERE userId = ? ORDER BY createdAt DESC" + ); + const selectAllStmt = db.prepare( + `SELECT k.id, k.userId, k.name, k.keyPrefix, k.createdAt, k.lastUsedAt, u.username + FROM api_keys k INNER JOIN users u ON u.id = k.userId + ORDER BY k.createdAt DESC` + ); + const selectByIdStmt = db.prepare( + "SELECT id, userId, name, keyPrefix, createdAt, lastUsedAt FROM api_keys WHERE id = ?" + ); + const deleteStmt = db.prepare("DELETE FROM api_keys WHERE id = ?"); + const deleteAllForUserStmt = db.prepare("DELETE FROM api_keys WHERE userId = ?"); + const countForUserStmt = db.prepare("SELECT COUNT(*) AS n FROM api_keys WHERE userId = ?"); + const validateStmt = db.prepare( + `SELECT k.id, k.userId, k.lastUsedAt, u.username, u.role + FROM api_keys k INNER JOIN users u ON u.id = k.userId + WHERE k.keyHash = ?` + ); + const touchStmt = db.prepare("UPDATE api_keys SET lastUsedAt = ? WHERE id = ?"); + + return { + create(userId, name) { + const count = (countForUserStmt.get(userId) as { n: number }).n; + if (count >= MAX_API_KEYS_PER_USER) { + return null; + } + const rawKey = `tsmb_${randomBytes(32).toString("base64url")}`; + const row: ApiKeyRow = { + id: randomUUID(), + userId, + name, + keyPrefix: rawKey.slice(0, API_KEY_PREFIX_LENGTH), + createdAt: Date.now(), + lastUsedAt: null, + }; + insertStmt.run(row.id, row.userId, row.name, hashKey(rawKey), row.keyPrefix, row.createdAt); + return { key: row, rawKey }; + }, + + listForUser(userId) { + return selectForUserStmt.all(userId) as ApiKeyRow[]; + }, + + listAll() { + return selectAllStmt.all() as ApiKeyWithUser[]; + }, + + delete(id, userId) { + const row = selectByIdStmt.get(id) as ApiKeyRow | undefined; + if (!row) return false; + if (userId !== undefined && row.userId !== userId) return false; + deleteStmt.run(id); + return true; + }, + + deleteAllForUser(userId) { + deleteAllForUserStmt.run(userId); + }, + + validateAndTouch(rawKey) { + if (!rawKey) return null; + const row = validateStmt.get(hashKey(rawKey)) as + | { id: string; userId: string; lastUsedAt: number | null; username: string; role: string } + | undefined; + if (!row) return null; + // The reserved guest principal must never authenticate via API keys; + // guest access is session-only by design. + if (row.role !== "admin" && row.role !== "member") return null; + const now = Date.now(); + if (row.lastUsedAt === null || now - row.lastUsedAt > API_KEY_TOUCH_INTERVAL_MS) { + touchStmt.run(now, row.id); + } + return { keyId: row.id, userId: row.userId, username: row.username, role: row.role }; + }, + }; +} diff --git a/src/data/audit.ts b/src/data/audit.ts index c3d8004..543a2e8 100644 --- a/src/data/audit.ts +++ b/src/data/audit.ts @@ -7,7 +7,9 @@ export type AuditAction = | "user.password_reset" | "user.password_changed" | "user.role_changed" - | "user.permissions_changed"; + | "user.permissions_changed" + | "api_key.created" + | "api_key.deleted"; export interface AuditEntry { id: number; diff --git a/src/data/database.ts b/src/data/database.ts index ddfe048..950850e 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -272,6 +272,18 @@ function initTables(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId); CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt); + CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + userId TEXT NOT NULL, + name TEXT NOT NULL, + keyHash TEXT NOT NULL UNIQUE, + keyPrefix TEXT NOT NULL, + createdAt INTEGER NOT NULL, + lastUsedAt INTEGER, + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_api_keys_userId ON api_keys(userId); + CREATE TABLE IF NOT EXISTS user_audit ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL, diff --git a/src/web/api/api-keys.test.ts b/src/web/api/api-keys.test.ts new file mode 100644 index 0000000..7ff4541 --- /dev/null +++ b/src/web/api/api-keys.test.ts @@ -0,0 +1,145 @@ +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, type SessionStore } from "../../data/sessions.js"; +import { createAuditStore } from "../../data/audit.js"; +import { createApiKeyStore, MAX_API_KEYS_PER_USER, type ApiKeyStore } from "../../data/api-keys.js"; +import { createPermissionStore } from "../../data/permissions.js"; +import { createRequireAuth } from "../middleware/requireAuth.js"; +import { createApiKeysRouter } from "./api-keys.js"; +import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; + +describe("api-keys router", () => { + let botDb: BotDatabase; + let app: express.Express; + let sessions: SessionStore; + let apiKeys: ApiKeyStore; + let adminId: string; + let memberId: string; + let adminToken: string; + let memberToken: string; + + beforeEach(async () => { + botDb = createDatabase(":memory:"); + const users = createUserStore(botDb.db); + sessions = createSessionStore(botDb.db); + const audit = createAuditStore(botDb.db); + const permissions = createPermissionStore(botDb.db); + apiKeys = createApiKeyStore(botDb.db); + const admin = await users.createUser("alice", "pw-alice", "admin"); + const member = await users.createUser("bob", "pw-bob", "member"); + adminId = admin.id; + memberId = member.id; + adminToken = sessions.createSession(adminId).token; + memberToken = sessions.createSession(memberId).token; + + app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use( + createRequireAuth(sessions, permissions, () => ({ + enabled: false, + bots: "all", + permissions: {} as any, + }), apiKeys) + ); + app.use("/api/keys", createApiKeysRouter(apiKeys, audit, { info: () => {}, warn: () => {}, error: () => {}, child: () => ({}) } as any)); + }); + + afterEach(() => { + botDb.close(); + }); + + const authed = (token: string) => { + const cookie = `${SESSION_COOKIE_NAME}=${token}`; + return { + get: (url: string) => request(app).get(url).set("Cookie", cookie), + post: (url: string) => request(app).post(url).set("Cookie", cookie), + delete: (url: string) => request(app).delete(url).set("Cookie", cookie), + }; + }; + const asAdmin = () => authed(adminToken); + const asMember = () => authed(memberToken); + + it("lists only the caller's own keys", async () => { + apiKeys.create(adminId, "mine"); + apiKeys.create(memberId, "theirs"); + const res = await asAdmin().get("/api/keys"); + expect(res.status).toBe(200); + expect(res.body.keys).toHaveLength(1); + expect(res.body.keys[0].name).toBe("mine"); + expect(res.body.keys[0].rawKey).toBeUndefined(); + }); + + it("creates a key and returns the plaintext exactly once", async () => { + const res = await asAdmin().post("/api/keys").send({ name: "ci" }); + expect(res.status).toBe(201); + expect(res.body.rawKey).toMatch(/^tsmb_/); + expect(apiKeys.validateAndTouch(res.body.rawKey)?.userId).toBe(adminId); + // The list view never exposes the plaintext again. + const list = await asAdmin().get("/api/keys"); + expect(JSON.stringify(list.body)).not.toContain(res.body.rawKey); + }); + + it("rejects creation without a valid name", async () => { + expect((await asAdmin().post("/api/keys").send({})).status).toBe(400); + expect((await asAdmin().post("/api/keys").send({ name: "" })).status).toBe(400); + expect((await asAdmin().post("/api/keys").send({ name: "x".repeat(65) })).status).toBe(400); + }); + + it("rejects creation beyond the per-user cap with 409", async () => { + for (let i = 0; i < MAX_API_KEYS_PER_USER; i++) { + apiKeys.create(memberId, `k${i}`); + } + const res = await asMember().post("/api/keys").send({ name: "overflow" }); + expect(res.status).toBe(409); + }); + + it("deletes own key and it stops validating", async () => { + const { key } = apiKeys.create(memberId, "ci")!; + const res = await asMember().delete(`/api/keys/${key.id}`); + expect(res.status).toBe(200); + expect(apiKeys.listForUser(memberId)).toHaveLength(0); + }); + + it("a member cannot delete another user's key", async () => { + const { key } = apiKeys.create(adminId, "admin-key")!; + const res = await asMember().delete(`/api/keys/${key.id}`); + expect(res.status).toBe(404); + expect(apiKeys.listForUser(adminId)).toHaveLength(1); + }); + + it("an admin can delete another user's key", async () => { + const { key } = apiKeys.create(memberId, "member-key")!; + const res = await asAdmin().delete(`/api/keys/${key.id}`); + expect(res.status).toBe(200); + expect(apiKeys.listForUser(memberId)).toHaveLength(0); + }); + + it("admin can list all keys with ?all=1, members cannot", async () => { + apiKeys.create(adminId, "a"); + apiKeys.create(memberId, "b"); + const adminAll = await asAdmin().get("/api/keys?all=1"); + expect(adminAll.body.keys).toHaveLength(2); + expect(adminAll.body.keys.map((k: any) => k.username).sort()).toEqual(["alice", "bob"]); + const memberAll = await asMember().get("/api/keys?all=1"); + expect(memberAll.body.keys).toHaveLength(1); + expect(memberAll.body.keys[0].name).toBe("b"); + }); + + it("a request authenticated by an API key cannot manage keys", async () => { + const { rawKey } = apiKeys.create(adminId, "self-mgmt")!; + const res = await request(app) + .post("/api/keys") + .set("Authorization", `Bearer ${rawKey}`) + .send({ name: "proliferate" }); + expect(res.status).toBe(403); + }); + + it("requires authentication", async () => { + expect((await request(app).get("/api/keys")).status).toBe(401); + }); +}); diff --git a/src/web/api/api-keys.ts b/src/web/api/api-keys.ts new file mode 100644 index 0000000..c278433 --- /dev/null +++ b/src/web/api/api-keys.ts @@ -0,0 +1,90 @@ +import { Router } from "express"; +import type { Request, Response, NextFunction } from "express"; +import type { ApiKeyStore } from "../../data/api-keys.js"; +import { MAX_API_KEYS_PER_USER } from "../../data/api-keys.js"; +import type { AuditStore } from "../../data/audit.js"; +import type { Logger } from "../../logger.js"; + +/** + * API-key management (list / create / revoke), mounted at /api/keys. + * Only interactive sessions may manage keys: a leaked key must never be able + * to mint its own replacements. + */ +export function createApiKeysRouter(apiKeys: ApiKeyStore, audit: AuditStore, logger: Logger): Router { + const router = Router(); + + const rejectApiKeyAuth = (req: Request, res: Response, next: NextFunction): void => { + if (req.authMethod === "api-key") { + res.status(403).json({ error: "API keys cannot manage API keys — log in to the WebUI" }); + return; + } + next(); + }; + router.use(rejectApiKeyAuth); + + // GET /api/keys — the caller's keys; admins may pass ?all=1 for every user's. + router.get("/", (req, res) => { + const user = req.user!; + if (req.query.all === "1" && user.role === "admin") { + res.json({ keys: apiKeys.listAll() }); + return; + } + res.json({ keys: apiKeys.listForUser(user.id) }); + }); + + // POST /api/keys — create a key; the plaintext is returned exactly once. + router.post("/", (req, res) => { + const user = req.user!; + const name = typeof req.body?.name === "string" ? req.body.name.trim() : ""; + if (!name || name.length > 64) { + res.status(400).json({ error: "name is required (1-64 characters)" }); + return; + } + const created = apiKeys.create(user.id, name); + if (!created) { + res.status(409).json({ error: `每个用户最多创建 ${MAX_API_KEYS_PER_USER} 个 API Key` }); + return; + } + try { + audit.record({ + actorId: user.id, + actorUsername: user.username, + targetUserId: user.id, + targetUsername: user.username, + action: "api_key.created", + }); + } catch (auditErr) { + logger.warn({ err: auditErr, action: "api_key.created" }, "audit insert failed"); + } + logger.info({ userId: user.id, keyId: created.key.id }, "API key created"); + res.status(201).json(created); + }); + + // DELETE /api/keys/:id — revoke; members only their own, admins any. + router.delete("/:id", (req, res) => { + const user = req.user!; + const ok = + user.role === "admin" + ? apiKeys.delete(req.params.id) + : apiKeys.delete(req.params.id, user.id); + if (!ok) { + res.status(404).json({ error: "API key not found" }); + return; + } + try { + audit.record({ + actorId: user.id, + actorUsername: user.username, + targetUserId: user.id, + targetUsername: user.username, + action: "api_key.deleted", + }); + } catch (auditErr) { + logger.warn({ err: auditErr, action: "api_key.deleted" }, "audit insert failed"); + } + logger.info({ userId: user.id, keyId: req.params.id }, "API key deleted"); + res.json({ success: true }); + }); + + return router; +} diff --git a/src/web/api/users.ts b/src/web/api/users.ts index 0e736fc..6197bec 100644 --- a/src/web/api/users.ts +++ b/src/web/api/users.ts @@ -3,6 +3,7 @@ import type { Logger } from "../../logger.js"; import type { UserStore } from "../../data/users.js"; import { UsernameTakenError, GUEST_USER_ID } from "../../data/users.js"; import type { SessionStore } from "../../data/sessions.js"; +import type { ApiKeyStore } from "../../data/api-keys.js"; import type { AuditStore } from "../../data/audit.js"; import { isCapability, BASIC_TIER_CAPABILITIES, type PermissionStore } from "../../data/permissions.js"; import { extractSessionToken } from "../auth/validateSession.js"; @@ -20,7 +21,8 @@ export function createUsersRouter( sessions: SessionStore, audit: AuditStore, logger: Logger, - permissions: PermissionStore + permissions: PermissionStore, + apiKeys?: ApiKeyStore ): Router { const router = Router(); @@ -85,6 +87,7 @@ export function createUsersRouter( } // FK CASCADE removes sessions; explicit call is belt-and-suspenders sessions.deleteAllForUser(targetId); + apiKeys?.deleteAllForUser(targetId); try { audit.record({ actorId: req.user!.id, actorUsername: req.user!.username, @@ -117,6 +120,9 @@ export function createUsersRouter( ? (extractSessionToken(req.headers.cookie) ?? undefined) : undefined; sessions.deleteAllForUser(targetId, exceptToken); + // A password reset must also kill the target's API keys — they are + // long-lived credentials that otherwise survive credential rotation. + apiKeys?.deleteAllForUser(targetId); try { audit.record({ actorId: req.user!.id, actorUsername: req.user!.username, diff --git a/src/web/auth/api-key-header.ts b/src/web/auth/api-key-header.ts new file mode 100644 index 0000000..d890797 --- /dev/null +++ b/src/web/auth/api-key-header.ts @@ -0,0 +1,36 @@ +import type { Request } from "express"; +import { SESSION_COOKIE_NAME } from "./validateSession.js"; + +/** + * Extract a raw API key from the `X-API-Key` header or an + * `Authorization: Bearer ` header. Returns null when neither is present. + */ +export function extractApiKey(req: Request): string | null { + const header = req.headers["x-api-key"]; + if (typeof header === "string" && header.trim()) { + return header.trim(); + } + const auth = req.headers.authorization; + if (typeof auth === "string") { + const match = /^bearer\s+(.+)$/i.exec(auth); + if (match) { + const key = match[1].trim(); + if (key) return key; + } + } + return null; +} + +export function hasApiKeyCredential(req: Request): boolean { + return extractApiKey(req) !== null; +} + +/** + * API-key clients (no session cookie) skip the origin check entirely. Requests + * that ALSO carry the session cookie must NOT rely on this — an attacker page + * can set arbitrary headers while the victim's cookie rides along ambiently, + * so the cookie keeps the request under the origin check. + */ +export function isApiKeyOnlyRequest(req: Request): boolean { + return hasApiKeyCredential(req) && !req.headers.cookie?.includes(`${SESSION_COOKIE_NAME}=`); +} diff --git a/src/web/middleware/csrf.test.ts b/src/web/middleware/csrf.test.ts index 24da36f..cb2a226 100644 --- a/src/web/middleware/csrf.test.ts +++ b/src/web/middleware/csrf.test.ts @@ -70,4 +70,28 @@ describe("csrfOriginCheck middleware", () => { expect(res.status).toBe(403); expect(res.body).toEqual({ error: "bad origin" }); }); + + // API-key clients authenticate via a header the browser never attaches + // automatically, so CSRF cannot abuse them — the origin check is skipped. + it("allows POST with an X-API-Key header and no session cookie", async () => { + const res = await request(app).post("/").set("X-API-Key", "tsmb_abc"); + expect(res.status).toBe(200); + }); + + it("allows POST with an Authorization: Bearer key and no session cookie", async () => { + const res = await request(app).post("/").set("Authorization", "Bearer tsmb_abc"); + expect(res.status).toBe(200); + }); + + it("does NOT skip the origin check when a session cookie rides along with an API key", async () => { + // An attacker page can set arbitrary headers while the victim's cookie is + // attached ambiently — the cookie keeps the request under the gate. + const res = await request(app) + .post("/") + .set("Host", "example.com") + .set("Origin", "https://evil.com") + .set("Cookie", "tsmb_session=whatever") + .set("X-API-Key", "tsmb_abc"); + expect(res.status).toBe(403); + }); }); diff --git a/src/web/middleware/csrf.ts b/src/web/middleware/csrf.ts index 9907eab..c42b862 100644 --- a/src/web/middleware/csrf.ts +++ b/src/web/middleware/csrf.ts @@ -1,4 +1,5 @@ import type { Request, Response, NextFunction } from "express"; +import { isApiKeyOnlyRequest } from "../auth/api-key-header.js"; const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); @@ -10,7 +11,7 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); * this header check covers the remaining attack surface. */ export function csrfOriginCheck(req: Request, res: Response, next: NextFunction): void { - if (SAFE_METHODS.has(req.method)) { + if (SAFE_METHODS.has(req.method) || isApiKeyOnlyRequest(req)) { next(); return; } diff --git a/src/web/middleware/requireAuth.test.ts b/src/web/middleware/requireAuth.test.ts index 3dac61f..98bfe41 100644 --- a/src/web/middleware/requireAuth.test.ts +++ b/src/web/middleware/requireAuth.test.ts @@ -5,6 +5,7 @@ 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 { createApiKeyStore } from "../../data/api-keys.js"; import { createPermissionStore } from "../../data/permissions.js"; import { createRequireAuth } from "./requireAuth.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; @@ -114,3 +115,112 @@ describe("requireAuth middleware", () => { expect(req.user.bots instanceof Set && req.user.bots.has("bot1")).toBe(true); }); }); + +describe("requireAuth middleware with API keys", () => { + let botDb: BotDatabase; + let app: express.Express; + let adminKey: string; + let memberKey: string; + + beforeEach(async () => { + botDb = createDatabase(":memory:"); + const users = createUserStore(botDb.db); + const sessions = createSessionStore(botDb.db); + const permissions = createPermissionStore(botDb.db); + const apiKeys = createApiKeyStore(botDb.db); + const admin = await users.createUser("alice", "pw-alice", "admin"); + const member = await users.createUser("bob", "pw-bob", "member"); + permissions.setPermissions(member.id, { capabilities: ["player.control"], bots: ["bot1"] }); + adminKey = apiKeys.create(admin.id, "ci")!.rawKey; + memberKey = apiKeys.create(member.id, "deploy")!.rawKey; + + app = express(); + app.use(cookieParser()); + app.use( + createRequireAuth(sessions, permissions, () => ({ + enabled: false, + bots: "all", + permissions: {} as any, + }), apiKeys) + ); + app.get("/protected", (req, res) => { + const u: any = (req as any).user; + res.json({ + ok: true, + authMethod: (req as any).authMethod, + user: u + ? { + username: u.username, + role: u.role, + capabilities: u.capabilities ? [...u.capabilities] : [], + bots: u.bots === "all" ? "all" : [...(u.bots ?? [])], + } + : null, + }); + }); + }); + + afterEach(() => { + botDb.close(); + }); + + it("authenticates a valid X-API-Key header and attaches the owner user", async () => { + const res = await request(app).get("/protected").set("X-API-Key", adminKey); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.user.username).toBe("alice"); + expect(res.body.user.role).toBe("admin"); + expect(res.body.authMethod).toBe("api-key"); + }); + + it("authenticates an Authorization: Bearer key", async () => { + const res = await request(app).get("/protected").set("Authorization", `Bearer ${adminKey}`); + expect(res.status).toBe(200); + expect(res.body.user.username).toBe("alice"); + }); + + it("rejects an unknown key with 401", async () => { + const res = await request(app).get("/protected").set("X-API-Key", "tsmb_bogus"); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "invalid api key" }); + }); + + it("ignores the session cookie when a key header is present", async () => { + // Garbage cookie + valid key → key wins. + const res = await request(app) + .get("/protected") + .set("Cookie", `${SESSION_COOKIE_NAME}=garbage`) + .set("X-API-Key", memberKey); + expect(res.status).toBe(200); + expect(res.body.user.username).toBe("bob"); + }); + + it("a member key inherits the member's capabilities and bot scope", async () => { + const res = await request(app).get("/protected").set("X-API-Key", memberKey); + expect(res.status).toBe(200); + expect(res.body.user.role).toBe("member"); + expect(res.body.user.capabilities).toContain("player.control"); + expect(res.body.user.bots).toContain("bot1"); + expect(res.body.user.capabilities).not.toContain("bot.manage"); + }); + + it("returns 401 when a key header is present but no store is wired", async () => { + const sessions: any = { validateAndTouch: () => null }; + const permissions: any = { getCapabilities: () => [], getBotAccess: () => [] }; + const mw = createRequireAuth(sessions, permissions, () => ({ enabled: false, bots: "all", permissions: {} as any })); + const req: any = { headers: { "x-api-key": "tsmb_x" } }; + const res: any = { status(c: number) { this.statusCode = c; return this; }, json() { return this; } }; + const next = vi.fn(); + mw(req, res, next); + expect(res.statusCode).toBe(401); + expect(next).not.toHaveBeenCalled(); + }); + + it("a key whose owner was deleted stops working", async () => { + const users = createUserStore(botDb.db); + const member = users.findByUsername("bob")!; + users.deleteUser(member.id); + const res = await request(app).get("/protected").set("X-API-Key", memberKey); + expect(res.status).toBe(401); + }); +}); diff --git a/src/web/middleware/requireAuth.ts b/src/web/middleware/requireAuth.ts index 1dc9b36..12736f6 100644 --- a/src/web/middleware/requireAuth.ts +++ b/src/web/middleware/requireAuth.ts @@ -1,6 +1,7 @@ import type { Request, Response, NextFunction, RequestHandler } from "express"; import type { SessionStore } from "../../data/sessions.js"; import { SESSION_TTL_MS } from "../../data/sessions.js"; +import type { ApiKeyStore } from "../../data/api-keys.js"; import { resolvePermissionContext, type PermissionStore, type GuestPermissions } from "../../data/permissions.js"; import type { GuestModeConfig } from "../../data/config.js"; import { @@ -8,6 +9,7 @@ import { extractSessionToken, SESSION_COOKIE_NAME, } from "../auth/validateSession.js"; +import { extractApiKey } from "../auth/api-key-header.js"; declare module "express-serve-static-core" { interface Request { @@ -19,15 +21,42 @@ declare module "express-serve-static-core" { bots?: "all" | Set; guest?: GuestPermissions; }; + /** How this request authenticated: browser session cookie or API key. */ + authMethod?: "session" | "api-key"; } } export function createRequireAuth( sessions: SessionStore, permissions: PermissionStore, - getGuestConfig: () => GuestModeConfig + getGuestConfig: () => GuestModeConfig, + apiKeys?: ApiKeyStore ): RequestHandler { return function requireAuth(req: Request, res: Response, next: NextFunction) { + // ─── API-key path ────────────────────────────────────────────────────── + // A key in a header authenticates on its own; cookies are ignored on this + // path so the two credential types can never be mixed. + const rawKey = extractApiKey(req); + if (rawKey !== null) { + const validation = apiKeys?.validateAndTouch(rawKey) ?? null; + if (!validation) { + res.status(401).json({ error: "invalid api key" }); + return; + } + const ctx = resolvePermissionContext(validation.role, validation.userId, permissions); + req.user = { + id: validation.userId, + username: validation.username, + role: validation.role, + capabilities: ctx.capabilities, + bots: ctx.bots, + }; + req.authMethod = "api-key"; + next(); + return; + } + + // ─── Session-cookie path (browser) ───────────────────────────────────── const result = validateSessionFromHeaders(req.headers.cookie, sessions); if (!result) { res.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); @@ -56,6 +85,7 @@ export function createRequireAuth( bots: ctx.bots, guest: ctx.guest, }; + req.authMethod = "session"; const token = extractSessionToken(req.headers.cookie); if (token) { res.cookie(SESSION_COOKIE_NAME, token, { diff --git a/src/web/server.ts b/src/web/server.ts index 045d7c7..f46df14 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -22,6 +22,7 @@ import { createFavoritesRouter } from "./api/favorites.js"; import { createPersonalMusicRouter } from "./api/personal-music.js"; import { createSavedQueuesRouter } from "./api/saved-queues.js"; import { createSpotifyRouter } from "./api/spotify.js"; +import { createApiKeysRouter } from "./api/api-keys.js"; import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; import type { SpotifyProvider } from "../music/spotify/provider.js"; import type { JellyfinProvider } from "../music/jellyfin.js"; @@ -33,6 +34,7 @@ import { import { setupWebSocket } from "./websocket.js"; import { createUserStore } from "../data/users.js"; import { createSessionStore } from "../data/sessions.js"; +import { createApiKeyStore } from "../data/api-keys.js"; import { createPermissionStore } from "../data/permissions.js"; import { createRequireAuth } from "./middleware/requireAuth.js"; import { requireAdmin } from "./middleware/requireAdmin.js"; @@ -102,6 +104,7 @@ export function createWebServer(options: WebServerOptions): WebServer { const sessions = createSessionStore(options.database.db); const audit = createAuditStore(options.database.db); const permissions = createPermissionStore(options.database.db); + const apiKeys = createApiKeyStore(options.database.db); // ─── Public routes (no auth, no CSRF) ─────────────────────────────────── // Disallow every crawler (issue #128). Declared before the static SPA @@ -131,7 +134,7 @@ export function createWebServer(options: WebServerOptions): WebServer { app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions, () => options.config.guestMode)); // ─── Gates for everything else under /api ─────────────────────────────── - const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode); + const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode, apiKeys); app.use("/api", csrfOriginCheck); app.use("/api", requireAuth); @@ -225,9 +228,13 @@ export function createWebServer(options: WebServerOptions): WebServer { ); // admin-only routes - app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions)); + app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions, apiKeys)); app.use("/api/audit", requireAdmin, createAuditRouter(audit)); + // API-key management — interactive sessions only (guests excluded; the + // router itself rejects key-authenticated requests). + app.use("/api/keys", requireNotGuest, createApiKeysRouter(apiKeys, audit, logger)); + // ─── Static SPA (public) ──────────────────────────────────────────────── if (options.staticDir) { app.use(express.static(options.staticDir)); diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index acf1a51..1fe388b 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -1135,6 +1135,60 @@ + +
+

API 密钥

+

+ 通过 API Key 调用本机的 REST API(请求头 Authorization: Bearer 或 X-API-Key)。 + 权限与你的账户一致;明文只在生成时显示一次,之后仅能看到前缀。 +

+
+
+ + +
+
还没有 API Key。
+
{{ apiKeyLoadError }}
+
+ +
+ + +
+

{{ apiKeyMutationError }}

+ + +
+
+ + +
+ {{ createdKey.rawKey }} + +
+

已复制到剪贴板

+
+ +
+
+
+
+

@@ -2200,6 +2254,90 @@ async function onConfirmReset() { } } +// --- API Keys management --- +interface ApiKeyEntry { id: string; name: string; keyPrefix: string; createdAt: number; lastUsedAt: number | null } +const apiKeyList = ref([]); +const apiKeyLoadError = ref(''); +const apiKeyMutationError = ref(''); +const newApiKeyName = ref(''); +const creatingApiKey = ref(false); +const createdKey = ref<{ key: ApiKeyEntry; rawKey: string } | null>(null); +const keyCopied = ref(false); + +async function loadApiKeys() { + apiKeyLoadError.value = ''; + try { + const res = await fetch('/api/keys'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + apiKeyList.value = body.keys ?? []; + } catch (e) { + apiKeyLoadError.value = (e as Error).message; + } +} + +async function onCreateApiKey() { + const name = newApiKeyName.value.trim(); + if (!name) return; + apiKeyMutationError.value = ''; + creatingApiKey.value = true; + try { + const res = await fetch('/api/keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`); + createdKey.value = body; + keyCopied.value = false; + newApiKeyName.value = ''; + await loadApiKeys(); + } catch (e) { + apiKeyMutationError.value = (e as Error).message; + } finally { + creatingApiKey.value = false; + } +} + +async function onRevokeApiKey(k: ApiKeyEntry) { + if (!confirm(`确认吊销 API Key「${k.name}」?使用它的集成将立即失效。`)) return; + apiKeyMutationError.value = ''; + try { + const res = await fetch(`/api/keys/${k.id}`, { method: 'DELETE' }); + if (!res.ok) { + const b = await res.json().catch(() => ({})); + throw new Error(b.error ?? `HTTP ${res.status}`); + } + await loadApiKeys(); + } catch (e) { + apiKeyMutationError.value = (e as Error).message; + } +} + +async function copyCreatedKey() { + if (!createdKey.value) return; + const text = createdKey.value.rawKey; + try { + await navigator.clipboard.writeText(text); + keyCopied.value = true; + } catch { + // Clipboard API is unavailable on non-secure origins — fall back to a + // temporary textarea + execCommand. + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + try { + keyCopied.value = document.execCommand('copy'); + } finally { + document.body.removeChild(ta); + } + } +} + // --- Per-user permission editor (members only) --- const CAPABILITIES: { token: string; label: string }[] = [ { token: 'player.control', label: '播放控制' }, @@ -2336,13 +2474,15 @@ function describeAction(e: AuditEntry): string { case 'user.password_changed': return `修改自己的密码`; case 'user.role_changed': return `变更 ${target} 的角色`; case 'user.permissions_changed': return `权限变更 → ${target}`; + case 'api_key.created': return `${target} 生成 API Key`; + case 'api_key.deleted': return `${target} 吊销 API Key`; 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'; + if (action === 'user.password_reset' || action === 'user.password_changed' || action === 'api_key.deleted') return 'audit-action-warn'; return 'audit-action-ok'; } @@ -2353,6 +2493,7 @@ onMounted(() => { loadIdleTimeout(); // also populates the Spotify config form (same endpoint) loadSpotifyStatus(); handleSpotifyRedirect(); + loadApiKeys(); if (session.isAdmin.value) { loadUsers(); loadAudit(); @@ -3148,6 +3289,18 @@ onUnmounted(() => { .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; } +.apikey-hint { font-size: 12px; color: var(--text-secondary); margin: 0 0 10px; line-height: 1.6; } +.apikey-hint code { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 4px; padding: 1px 4px; } +.apikey-meta { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--text-secondary); margin-top: 2px; } +.apikey-prefix { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; } +.apikey-raw-row { display: flex; gap: 8px; align-items: center; } +.apikey-raw { + flex: 1; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; word-break: break-all; padding: 8px; + background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 6px; + user-select: all; +} +.apikey-copied { font-size: 12px; color: var(--color-primary); margin: 6px 0 0; } .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; }