Merge pull request #173 from senlinjun/feat/restapi

This commit is contained in:
TIANYAO ZHANG committed 2026-10-03 16:50:09 +08:00
commit bdb33df89d
16 files changed
+1288 -7

No files matched your search

+46
View File
@@ -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/<botId>/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/<botId>/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/<botId>/pause
curl -X POST -H "X-API-Key: $KEY" http://127.0.0.1:3000/api/player/<botId>/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/<botId>/volume
```
全部端点、参数与返回值见 **[docs/API.md](docs/API.md)**。认证失败返回 `401 {"error":"invalid api key"}`,越权返回 `403`。
## 项目架构
```
+365
View File
@@ -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<Song, "vip"> {
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 次的限流。
+124
View File
@@ -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"]);
});
});
+130
View File
@@ -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 };
},
};
}
+3 -1
View File
@@ -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;
+12
View File
@@ -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,
+145
View File
@@ -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);
});
});
+90
View File
@@ -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;
}
+7 -1
View File
@@ -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,
+36
View File
@@ -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 <key>` 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}=`);
}
+24
View File
@@ -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);
});
});
+2 -1
View File
@@ -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;
}
+110
View File
@@ -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);
});
});
+31 -1
View File
@@ -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<string>;
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, {
+9 -2
View File
@@ -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));
+154 -1
View File
@@ -1135,6 +1135,60 @@
</div>
</section>
<!-- API Keys -->
<section class="settings-section">
<h2 class="section-title">API 密钥</h2>
<p class="apikey-hint">
通过 API Key 调用本机的 REST API(请求头 <code>Authorization: Bearer</code> 或 <code>X-API-Key</code>)。
权限与你的账户一致;明文只在生成时显示一次,之后仅能看到前缀。
</p>
<div class="user-list">
<div v-for="k in apiKeyList" :key="k.id" class="user-item">
<div class="user-info">
<div class="user-name">{{ k.name }}</div>
<div class="apikey-meta">
<code class="apikey-prefix">{{ k.keyPrefix }}…</code>
<span>创建于 {{ formatDate(k.createdAt) }}</span>
<span>{{ k.lastUsedAt ? `最后使用 ${formatDate(k.lastUsedAt)}` : '从未使用' }}</span>
</div>
</div>
<div class="user-actions">
<button class="btn-sm btn-delete" title="吊销此密钥" @click="onRevokeApiKey(k)">
<Icon icon="mdi:delete" />
</button>
</div>
</div>
<div v-if="apiKeyList.length === 0 && !apiKeyLoadError" class="user-empty">还没有 API Key。</div>
<div v-if="apiKeyLoadError" class="user-error">{{ apiKeyLoadError }}</div>
</div>
<form class="user-add-form" @submit.prevent="onCreateApiKey">
<input v-model="newApiKeyName" class="input" placeholder="密钥名称(如:home-assistant)" maxlength="64" required />
<button class="btn-sm btn-primary" type="submit" :disabled="creatingApiKey">
{{ creatingApiKey ? '生成中…' : '生成密钥' }}
</button>
</form>
<p v-if="apiKeyMutationError" class="user-error">{{ apiKeyMutationError }}</p>
<!-- Created key modal: plaintext shown exactly once -->
<div v-if="createdKey" class="edit-modal-overlay" @click.self="createdKey = null">
<div class="edit-modal">
<h3 class="modal-title">密钥「{{ createdKey.key.name }}」已生成</h3>
<p class="modal-hint">请立即复制保存——这串明文只显示这一次,关闭后只能看到前缀。</p>
<div class="apikey-raw-row">
<code class="apikey-raw">{{ createdKey.rawKey }}</code>
<button class="btn-sm" @click="copyCreatedKey">
<Icon icon="mdi:content-copy" /> 复制
</button>
</div>
<p v-if="keyCopied" class="apikey-copied">已复制到剪贴板</p>
<div class="form-actions">
<button class="btn-sm btn-primary" @click="createdKey = null">完成</button>
</div>
</div>
</div>
</section>
<!-- Audit Log -->
<section v-if="session.isAdmin.value" class="settings-section">
<h2 class="section-title">
@@ -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<ApiKeyEntry[]>([]);
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; }