试用前排查:WebSocket 心跳与断线重连、登录失败按账号限制、旧浏览器提示
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

- 服务器 30 秒发一次 ping:Nginx Proxy Manager 默认 60 秒没数据就断开,窗体开着不操作程序会被结束
- 连接意外断开时提示、清掉失效的窗体、自动重连,连上后提示重新运行
- 登录失败:同一账号 10 次只锁这个账号,同一 IP 300 次,登录成功不再清零 IP 计数
- index.html:浏览器太旧时说明原因和解决办法,加载慢时提示稍等

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 09:05:05 +08:00
1 parent 982b1297aa
commit 27fdc3615d
8 files changed
+137 -23

No files matched your search

+2 -1
View File
@@ -50,8 +50,9 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
app.post('/api/login', async (c) => {
const ip = clientIp(c)
if (tooManyFailures(ip)) return c.json({ message: '输错次数太多,请 10 分钟后再试' }, 429)
const { username, password } = await body<{ username: string; password: string }>(c)
const blocked = tooManyFailures(ip, String(username ?? ''))
if (blocked) return c.json({ message: blocked }, 429)
const tokens = await login(db, String(username ?? ''), String(password ?? ''), ip)
if (!tokens) return c.json({ message: '账号或密码不对' }, 401)
return c.json(await withUser(tokens))
+27 -15
View File
@@ -12,8 +12,10 @@ import { config } from './config'
const ACCESS_SECONDS = 15 * 60
const REFRESH_DAYS = 30
// 全班通常共用学校的一个出口 IP,上限不能太小,否则几个同学输错就把全班挡在外面
const MAX_FAILURES = 30
// 防暴力猜密码:同一个账号输错太多次只锁这个账号(学生密码是 6 位数字,不能让人一直猜);
// 全班通常共用学校的一个出口 IP,按 IP 的上限要大,否则几个同学输错就把全班挡在外面
const MAX_FAILURES_PER_ACCOUNT = 10
const MAX_FAILURES_PER_IP = 300
const FAILURE_WINDOW_MS = 10 * 60_000
export interface User {
@@ -55,17 +57,27 @@ function secret(db: Database): string {
const randomString = () => Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')
const hash = (token: string) => new Bun.CryptoHasher('sha256').update(token).digest('hex')
/** 每个 IP 一段时间内的失败次数,防止暴力猜密码 */
/** 一段时间内的失败次数,键是 "ip:地址" 或 "user:账号" */
const failures = new Map<string, { count: number; since: number }>()
export function tooManyFailures(ip: string) {
const entry = failures.get(ip)
if (!entry) return false
if (Date.now() - entry.since > FAILURE_WINDOW_MS) {
failures.delete(ip)
return false
}
return entry.count >= MAX_FAILURES
function failureCount(key: string) {
const entry = failures.get(key)
if (entry && Date.now() - entry.since > FAILURE_WINDOW_MS) failures.delete(key)
return failures.get(key)?.count ?? 0
}
function addFailure(key: string) {
failureCount(key) // 先清掉过期的
const entry = failures.get(key) ?? { count: 0, since: Date.now() }
entry.count++
failures.set(key, entry)
}
/** 输错太多次时返回提示,否则返回 null */
export function tooManyFailures(ip: string, username: string): string | null {
if (failureCount(`user:${username.trim()}`) >= MAX_FAILURES_PER_ACCOUNT) return '这个账号的密码输错太多次,请 10 分钟后再试,或请老师查看密码'
if (failureCount(`ip:${ip}`) >= MAX_FAILURES_PER_IP) return '输错次数太多,请 10 分钟后再试'
return null
}
async function issueTokens(db: Database, userId: number, tokenVersion: number): Promise<Tokens> {
@@ -86,12 +98,12 @@ export async function login(db: Database, username: string, password: string, ip
.query<{ id: number; password: string; token_version: number }, [string]>('SELECT id, password, token_version FROM users WHERE username = ?')
.get(username.trim())
if (!row || row.password !== password) {
const entry = failures.get(ip) ?? { count: 0, since: Date.now() }
entry.count++
failures.set(ip, entry)
addFailure(`ip:${ip}`)
addFailure(`user:${username.trim()}`)
return null
}
failures.delete(ip)
// 只清这个账号的;按 IP 的不清,否则用自己的账号登录一次就能接着猜别人的密码
failures.delete(`user:${username.trim()}`)
return issueTokens(db, row.id, row.token_version)
}
+4
View File
@@ -52,6 +52,10 @@ const server = Bun.serve({
},
websocket: {
data: {} as SocketData,
// 30 秒没收到数据就发一次 ping(浏览器自动回 pong)。线上前面有 Nginx Proxy Manager,
// nginx 默认 60 秒没数据就断开 WebSocket,学生的窗体开着不操作一分钟程序就会被结束
idleTimeout: 30,
sendPings: true,
open(ws) {
const userId = ws.data.userId!
ws.data.session = new RunSession((message) => ws.send(JSON.stringify(message)), () => userDatabaseDir(userId))
+27 -5
View File
@@ -90,11 +90,33 @@ test('账号或密码不对时登录失败,老师登录后能看到自己的
expect(me.body.user).toMatchObject({ username: config.teacherUsername, role: 'teacher' })
})
test('同一个 IP 输错 30 次后暂停登录,别的 IP 不受影响', async () => {
const attacker = new Browser('10.0.0.66')
for (let i = 0; i < 30; i++) await attacker.login('admin', `guess${i}`)
expect((await attacker.login('admin', 'whatever')).status).toBe(429)
expect((await new Browser('10.0.0.67').login('admin', 'whatever')).status).toBe(401)
test('同一个账号输错 10 次后暂停这个账号的登录,同一 IP 的其他同学不受影响', async () => {
const t = await teacher()
await classWithStudents(t, [
{ username: 'guessed', name: '甲', password: '123456' },
{ username: 'other', name: '乙', password: '654321' },
])
const classmate = new Browser('10.0.0.66')
for (let i = 0; i < 9; i++) await classmate.login('guessed', `00000${i}`)
// 用自己的账号登录成功,不会清掉别人账号的失败次数
expect((await classmate.login('other', '654321')).status).toBe(200)
await classmate.login('guessed', '000009')
const blocked = await new Browser('10.0.0.67').login('guessed', '123456')
expect(blocked.status).toBe(429)
expect(blocked.body.message).toContain('这个账号')
expect((await new Browser('10.0.0.66').login('other', '654321')).status).toBe(200)
})
test('同一个 IP 输错 300 次后暂停登录,登录成功也不清零,别的 IP 不受影响', async () => {
const t = await teacher()
await classWithStudents(t, [{ username: 'mine', name: '甲', password: '111111' }])
const attacker = new Browser('10.0.0.68')
for (let i = 0; i < 300; i++) {
await attacker.login(`nobody${i % 50}`, 'guess')
if (i === 150) expect((await attacker.login('mine', '111111')).status).toBe(200)
}
expect((await attacker.login('mine', '111111')).status).toBe(429)
expect((await new Browser('10.0.0.69').login('mine', '111111')).status).toBe(200)
})
describe('班级和名单', () => {