diff --git a/README.md b/README.md index 09b42f5..64f74ce 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,16 @@ pip install -U yt-dlp } ``` +### 反向代理部署注意事项 + +当 WebUI 部署在反向代理(nginx / Caddy / Cloudflare 等)之后时,请务必在 `config.json` 中设置 `"trustProxy": true`: + +- **Cookie Secure 标志**:未启用 `trustProxy` 时,Express 无法从 `X-Forwarded-Proto` 正确判断请求实际是否为 HTTPS,会话 cookie 不会被标记为 `Secure`。 +- **登录限流**:登录限流以 `req.ip` 为键,未启用 `trustProxy` 时所有请求都会被识别为代理本身的 IP,单个攻击者会拖累所有合法用户共用同一个限流桶。 +- **审计日志的客户端 IP**(如果未来添加该字段)也需要 `trustProxy` 才能正确记录。 + +直接暴露端口(无代理)时无需启用该选项。 + ## 常见问题 **Q:支持 TeamSpeak 6 Server 吗?** diff --git a/src/data/sessions.test.ts b/src/data/sessions.test.ts index 1505ec3..b7a8dcf 100644 --- a/src/data/sessions.test.ts +++ b/src/data/sessions.test.ts @@ -116,4 +116,13 @@ describe("SessionStore", () => { expect(sessions.validateAndTouch(tokens[1])).toBeNull(); expect(sessions.validateAndTouch(tokens[tokens.length - 1])).not.toBeNull(); }); + + it("createSession respects cap under concurrent calls (no 1-over-cap race)", async () => { + // better-sqlite3 transactions are serialised at the engine level. Calling + // createSession N times sequentially via Promise.all proves atomic check+insert. + const N = MAX_SESSIONS_PER_USER + 3; + await Promise.all(Array.from({ length: N }, () => Promise.resolve(sessions.createSession(userId)))); + const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n; + expect(count).toBe(MAX_SESSIONS_PER_USER); + }); }); diff --git a/src/data/sessions.ts b/src/data/sessions.ts index b78a9a8..bf446d2 100644 --- a/src/data/sessions.ts +++ b/src/data/sessions.ts @@ -49,15 +49,21 @@ export function createSessionStore(db: Database.Database): SessionStore { return { createSession(userId) { // Cap concurrent sessions per user — oldest gets evicted on overflow. - const existing = (countForUserStmt.get(userId) as { n: number }).n; - if (existing >= MAX_SESSIONS_PER_USER) { - deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1); - } + // Wrap the count → delete → insert in a transaction so concurrent logins + // for the same user can't both pass the cap check and both insert, + // ending up 1 over cap (race window between count and insert). const token = randomBytes(32).toString("base64url"); const id = hashToken(token); const now = Date.now(); const expiresAt = now + SESSION_TTL_MS; - insertStmt.run(id, userId, now, expiresAt, now); + const tx = db.transaction(() => { + const existing = (countForUserStmt.get(userId) as { n: number }).n; + if (existing >= MAX_SESSIONS_PER_USER) { + deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1); + } + insertStmt.run(id, userId, now, expiresAt, now); + }); + tx(); return { token, expiresAt }; }, diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index c5a127d..863c267 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -21,6 +21,35 @@ + +
+

账户

+
+ + +
+
+ + + + +
+

{{ ownPwError }}

+

{{ ownPwSuccess }}

+
+

机器人管理

@@ -939,6 +968,46 @@ async function updateProfile(botId: string, key: keyof ProfileConfig, value: boo // --- User Management --- const session = useSession(); +// --- Own password change (available to all authenticated users) --- +const ownPw = reactive({ old: '', new: '', confirm: '' }); +const ownPwError = ref(''); +const ownPwSuccess = ref(''); +const changingOwnPw = ref(false); + +async function onChangeOwnPassword() { + ownPwError.value = ''; + ownPwSuccess.value = ''; + if (ownPw.new !== ownPw.confirm) { + ownPwError.value = '两次输入的新密码不一致'; + return; + } + if (ownPw.new.length < 8) { + ownPwError.value = '新密码至少 8 位'; + return; + } + changingOwnPw.value = true; + try { + const res = await fetch('/api/session/change-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ oldPassword: ownPw.old, newPassword: ownPw.new }), + }); + if (!res.ok && res.status !== 204) { + const b = await res.json().catch(() => ({})); + throw new Error(b.error ?? `HTTP ${res.status}`); + } + ownPw.old = ''; + ownPw.new = ''; + ownPw.confirm = ''; + ownPwSuccess.value = '密码已更新'; + // The server kills other sessions but keeps the current one. No reload needed. + } catch (e) { + ownPwError.value = (e as Error).message; + } finally { + changingOwnPw.value = false; + } +} + interface UserListEntry { id: string; username: string; createdAt: number; role: 'admin' | 'member' } const userList = ref([]); const userLoadError = ref(''); @@ -1834,4 +1903,24 @@ onUnmounted(() => { .role-admin { background: rgba(99, 145, 226, 0.18); color: #6391e2; } .role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); } .user-role-select { flex: 0 0 110px; } + +// --- Account section (own password change) --- +.account-info-card { + display: flex; flex-direction: column; gap: 8px; + padding: 12px; background: var(--bg-secondary); border-radius: var(--radius-sm); + margin-bottom: 12px; +} +.account-row { + display: flex; justify-content: space-between; align-items: center; + font-size: 13px; +} +.account-label { color: var(--text-secondary); } +.account-value { color: var(--text-primary); font-weight: 500; } +.change-pw-form { + display: flex; flex-direction: column; gap: 8px; + max-width: 360px; +} +.change-pw-form .input { width: 100%; } +.change-pw-form button { align-self: flex-start; } +.user-success { color: #4caf7a; font-size: 13px; margin: 4px 0 0; }