mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(auth): atomic session cap + change-password UI + trustProxy docs
This commit is contained in:
1 parent
a39fc25104
commit
b6b9aa07bc
4 files changed
+119
-5
No files matched your search
@@ -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 吗?**
|
**Q:支持 TeamSpeak 6 Server 吗?**
|
||||||
|
|||||||
@@ -116,4 +116,13 @@ describe("SessionStore", () => {
|
|||||||
expect(sessions.validateAndTouch(tokens[1])).toBeNull();
|
expect(sessions.validateAndTouch(tokens[1])).toBeNull();
|
||||||
expect(sessions.validateAndTouch(tokens[tokens.length - 1])).not.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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+11
-5
@@ -49,15 +49,21 @@ export function createSessionStore(db: Database.Database): SessionStore {
|
|||||||
return {
|
return {
|
||||||
createSession(userId) {
|
createSession(userId) {
|
||||||
// Cap concurrent sessions per user — oldest gets evicted on overflow.
|
// Cap concurrent sessions per user — oldest gets evicted on overflow.
|
||||||
const existing = (countForUserStmt.get(userId) as { n: number }).n;
|
// Wrap the count → delete → insert in a transaction so concurrent logins
|
||||||
if (existing >= MAX_SESSIONS_PER_USER) {
|
// for the same user can't both pass the cap check and both insert,
|
||||||
deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1);
|
// ending up 1 over cap (race window between count and insert).
|
||||||
}
|
|
||||||
const token = randomBytes(32).toString("base64url");
|
const token = randomBytes(32).toString("base64url");
|
||||||
const id = hashToken(token);
|
const id = hashToken(token);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const expiresAt = now + SESSION_TTL_MS;
|
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 };
|
return { token, expiresAt };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Account: own password change -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="section-title">账户</h2>
|
||||||
|
<div class="account-info-card">
|
||||||
|
<div class="account-row">
|
||||||
|
<span class="account-label">用户名</span>
|
||||||
|
<span class="account-value">{{ session.currentUser.value?.username ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="account-row">
|
||||||
|
<span class="account-label">角色</span>
|
||||||
|
<span class="account-value">
|
||||||
|
<span class="user-role-badge" :class="`role-${session.currentUser.value?.role}`">
|
||||||
|
{{ session.currentUser.value?.role === 'admin' ? '管理员' : '成员' }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="change-pw-form" @submit.prevent="onChangeOwnPassword">
|
||||||
|
<input v-model="ownPw.old" type="password" autocomplete="current-password" class="input" placeholder="当前密码" required />
|
||||||
|
<input v-model="ownPw.new" type="password" autocomplete="new-password" minlength="8" class="input" placeholder="新密码 (≥8 位)" required />
|
||||||
|
<input v-model="ownPw.confirm" type="password" autocomplete="new-password" minlength="8" class="input" placeholder="再次输入新密码" required />
|
||||||
|
<button class="btn-sm btn-primary" type="submit" :disabled="changingOwnPw">
|
||||||
|
{{ changingOwnPw ? '更新中…' : '修改密码' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="ownPwError" class="user-error">{{ ownPwError }}</p>
|
||||||
|
<p v-if="ownPwSuccess" class="user-success">{{ ownPwSuccess }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Bot Management -->
|
<!-- Bot Management -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2 class="section-title">机器人管理</h2>
|
<h2 class="section-title">机器人管理</h2>
|
||||||
@@ -939,6 +968,46 @@ async function updateProfile(botId: string, key: keyof ProfileConfig, value: boo
|
|||||||
// --- User Management ---
|
// --- User Management ---
|
||||||
const session = useSession();
|
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' }
|
interface UserListEntry { id: string; username: string; createdAt: number; role: 'admin' | 'member' }
|
||||||
const userList = ref<UserListEntry[]>([]);
|
const userList = ref<UserListEntry[]>([]);
|
||||||
const userLoadError = ref('');
|
const userLoadError = ref('');
|
||||||
@@ -1834,4 +1903,24 @@ onUnmounted(() => {
|
|||||||
.role-admin { background: rgba(99, 145, 226, 0.18); color: #6391e2; }
|
.role-admin { background: rgba(99, 145, 226, 0.18); color: #6391e2; }
|
||||||
.role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
|
.role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
|
||||||
.user-role-select { flex: 0 0 110px; }
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
Reference in new issue
Block a user