fix: LOW 问题修复(第四批)

- L1: bcrypt.compareSync 改为异步 bcrypt.compare
- L2: 登录速率限制(5次/15分钟,用户名+IP 维度)
- L4: 取消按钮在保存中禁用,防止用户丢失错误信息
- L5: localadmin INSERT OR IGNORE 添加设计决策注释
This commit is contained in:
aiyimickey 2026-07-03 14:01:33 +08:00
parent 38e97faf0c
commit 2658438c48
4 changed files with 73 additions and 14 deletions

View File

@ -202,8 +202,8 @@ export default function UsersPage() {
</div> </div>
<div className="flex justify-end gap-3 mt-6"> <div className="flex justify-end gap-3 mt-6">
<button onClick={() => setEditingUser(null)} <button onClick={() => setEditingUser(null)} disabled={saving}
className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800"></button> className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-50"></button>
<button onClick={handleSave} disabled={saving} <button onClick={handleSave} disabled={saving}
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"> className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
{saving ? '保存中...' : '保存'} {saving ? '保存中...' : '保存'}

View File

@ -4,8 +4,9 @@ import bcrypt from 'bcryptjs'
import { signJwt } from '@shared/lib/auth/jwt' import { signJwt } from '@shared/lib/auth/jwt'
import { ldapAuth } from '@shared/lib/auth/ldap' import { ldapAuth } from '@shared/lib/auth/ldap'
import { authConfig } from '@/lib/auth-config' import { authConfig } from '@/lib/auth-config'
import { dbQuery, dbExec, escapeSql } from '@/lib/db' import { dbQuery, escapeSql } from '@/lib/db'
import { writeAuditLog } from '@shared/lib/audit/write-audit-log' import { writeAuditLog } from '@/lib/audit'
import { checkRateLimit, resetRateLimit } from '@/lib/rate-limit'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
let username: string, password: string let username: string, password: string
@ -22,6 +23,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '用户名和密码不能为空' }, { status: 400 }) return NextResponse.json({ error: '用户名和密码不能为空' }, { status: 400 })
} }
// 速率限制5次/15分钟
const ip = request.headers.get('x-forwarded-for') || '127.0.0.1'
const rateLimitKey = `login:${username}:${ip}`
const { allowed, retryAfterMs } = checkRateLimit(rateLimitKey)
if (!allowed) {
return NextResponse.json({ error: `登录尝试过于频繁,请 ${Math.ceil(retryAfterMs / 60000)} 分钟后重试` }, { status: 429 })
}
// localadmin 密码验证(查询数据库存储的密码) // localadmin 密码验证(查询数据库存储的密码)
if (username === 'localadmin') { if (username === 'localadmin') {
const users = dbQuery<{ password_hash: string; role: string; display_name: string | null }>(`SELECT password_hash, role, display_name FROM users WHERE username = 'localadmin'`) const users = dbQuery<{ password_hash: string; role: string; display_name: string | null }>(`SELECT password_hash, role, display_name FROM users WHERE username = 'localadmin'`)
@ -29,24 +38,25 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'localadmin 未配置' }, { status: 401 }) return NextResponse.json({ error: 'localadmin 未配置' }, { status: 401 })
} }
const localadminUser = users[0] const localadminUser = users[0]
// 与数据库存储的 bcrypt 哈希比较 // 与数据库存储的 bcrypt 哈希比较(异步避免阻塞事件循环)
if (!bcrypt.compareSync(password, localadminUser.password_hash)) { if (!await bcrypt.compare(password, localadminUser.password_hash)) {
writeAuditLog({ exec: dbExec }, { writeAuditLog({
username, action: 'login_failed', entityType: 'auth', username, action: 'login_failed', entityType: 'auth',
details: { method: 'localadmin', reason: 'wrong_password' }, details: { method: 'localadmin', reason: 'wrong_password' },
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1', ipAddress: ip,
}) })
return NextResponse.json({ error: '密码错误' }, { status: 401 }) return NextResponse.json({ error: '密码错误' }, { status: 401 })
} }
resetRateLimit(rateLimitKey)
const displayName = localadminUser.display_name || 'localadmin' const displayName = localadminUser.display_name || 'localadmin'
const role = localadminUser.role || 'admin' const role = localadminUser.role || 'admin'
const token = signJwt({ secret: authConfig.jwtSecret, payload: { username: 'localadmin', role, displayName } }) const token = signJwt({ secret: authConfig.jwtSecret, payload: { username: 'localadmin', role, displayName } })
writeAuditLog({ exec: dbExec }, { writeAuditLog({
username, action: 'login', entityType: 'auth', username, action: 'login', entityType: 'auth',
details: { method: 'localadmin' }, details: { method: 'localadmin' },
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1', ipAddress: ip,
}) })
const response = NextResponse.json({ const response = NextResponse.json({
@ -65,22 +75,24 @@ export async function POST(request: NextRequest) {
) )
if (!result.success) { if (!result.success) {
writeAuditLog({ exec: dbExec }, { writeAuditLog({
username, action: 'login_failed', entityType: 'auth', username, action: 'login_failed', entityType: 'auth',
details: { method: 'ldap', reason: result.error }, details: { method: 'ldap', reason: result.error },
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1', ipAddress: ip,
}) })
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 }) return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
} }
resetRateLimit(rateLimitKey)
// 签发 JWT // 签发 JWT
const users = dbQuery(`SELECT role FROM users WHERE username = ${escapeSql(username)}`) const users = dbQuery(`SELECT role FROM users WHERE username = ${escapeSql(username)}`)
const role = users.length > 0 ? users[0].role as string : 'viewer' const role = users.length > 0 ? users[0].role as string : 'viewer'
writeAuditLog({ exec: dbExec }, { writeAuditLog({
username, action: 'login', entityType: 'auth', username, action: 'login', entityType: 'auth',
details: { method: 'ldap', role }, details: { method: 'ldap', role },
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1', ipAddress: ip,
}) })
const token = signJwt({ secret: authConfig.jwtSecret, payload: { username, role, displayName: result.displayName || username } }) const token = signJwt({ secret: authConfig.jwtSecret, payload: { username, role, displayName: result.displayName || username } })

View File

@ -38,6 +38,8 @@ function ensureDb() {
execRaw(`CREATE TABLE IF NOT EXISTS role_permissions (id INTEGER PRIMARY KEY AUTOINCREMENT, role TEXT NOT NULL, permission_key TEXT NOT NULL REFERENCES permissions(key), created_at TEXT DEFAULT (datetime('now', '+8 hours')), UNIQUE(role, permission_key))`) execRaw(`CREATE TABLE IF NOT EXISTS role_permissions (id INTEGER PRIMARY KEY AUTOINCREMENT, role TEXT NOT NULL, permission_key TEXT NOT NULL REFERENCES permissions(key), created_at TEXT DEFAULT (datetime('now', '+8 hours')), UNIQUE(role, permission_key))`)
const bcrypt = require('bcryptjs') const bcrypt = require('bcryptjs')
const pwdHash = bcrypt.hashSync(config.localadminPassword, 10) const pwdHash = bcrypt.hashSync(config.localadminPassword, 10)
// 设计决策INSERT OR IGNORE 确保只在首次初始化时写入 localadmin 密码。
// 如果 LOCALADMIN_PASSWORD 环境变量变更,需要通过管理后台手动修改密码。
execRaw(`INSERT OR IGNORE INTO users (username, display_name, role, password_hash) VALUES ('localadmin', '超级管理员', 'admin', '${pwdHash.replace(/'/g, "''")}')`) execRaw(`INSERT OR IGNORE INTO users (username, display_name, role, password_hash) VALUES ('localadmin', '超级管理员', 'admin', '${pwdHash.replace(/'/g, "''")}')`)
const raw = execFileSync('sqlite3', ['-json', DB_PATH, 'SELECT id FROM alert_channels'], { encoding: 'utf-8', timeout: 10000 }).trim() const raw = execFileSync('sqlite3', ['-json', DB_PATH, 'SELECT id FROM alert_channels'], { encoding: 'utf-8', timeout: 10000 }).trim()
const channels = raw ? JSON.parse(raw) : [] const channels = raw ? JSON.parse(raw) : []

45
src/lib/rate-limit.ts Normal file
View File

@ -0,0 +1,45 @@
// src/lib/rate-limit.ts — 简易内存速率限制器
// 注意:仅适用于单实例部署,重启后计数器重置
interface AttemptRecord {
count: number
resetAt: number
}
const attempts = new Map<string, AttemptRecord>()
const MAX_ATTEMPTS = 5
const WINDOW_MS = 15 * 60 * 1000 // 15 分钟
// 清理过期记录(每 5 分钟执行一次)
let lastCleanup = Date.now()
function cleanup() {
const now = Date.now()
if (now - lastCleanup < 5 * 60 * 1000) return
lastCleanup = now
for (const [key, record] of attempts) {
if (record.resetAt <= now) attempts.delete(key)
}
}
export function checkRateLimit(key: string): { allowed: boolean; retryAfterMs: number } {
cleanup()
const now = Date.now()
const record = attempts.get(key)
if (!record || record.resetAt <= now) {
// 窗口已过期或首次尝试
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS })
return { allowed: true, retryAfterMs: 0 }
}
if (record.count >= MAX_ATTEMPTS) {
return { allowed: false, retryAfterMs: record.resetAt - now }
}
record.count++
return { allowed: true, retryAfterMs: 0 }
}
export function resetRateLimit(key: string) {
attempts.delete(key)
}