试用前排查:WebSocket 心跳与断线重连、登录失败按账号限制、旧浏览器提示
- 服务器 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:
1 parent
982b1297aa
commit
27fdc3615d
8 files changed
+137
-23
No files matched your search
+2
-1
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user