fix: LOW 问题修复(第四批)
- L1: bcrypt.compareSync 改为异步 bcrypt.compare - L2: 登录速率限制(5次/15分钟,用户名+IP 维度) - L4: 取消按钮在保存中禁用,防止用户丢失错误信息 - L5: localadmin INSERT OR IGNORE 添加设计决策注释
This commit is contained in:
parent
38e97faf0c
commit
2658438c48
|
|
@ -202,8 +202,8 @@ export default function UsersPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button onClick={() => setEditingUser(null)}
|
||||
className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800">取消</button>
|
||||
<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 disabled:opacity-50">取消</button>
|
||||
<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">
|
||||
{saving ? '保存中...' : '保存'}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import bcrypt from 'bcryptjs'
|
|||
import { signJwt } from '@shared/lib/auth/jwt'
|
||||
import { ldapAuth } from '@shared/lib/auth/ldap'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||
import { writeAuditLog } from '@shared/lib/audit/write-audit-log'
|
||||
import { dbQuery, escapeSql } from '@/lib/db'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
import { checkRateLimit, resetRateLimit } from '@/lib/rate-limit'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let username: string, password: string
|
||||
|
|
@ -22,6 +23,14 @@ export async function POST(request: NextRequest) {
|
|||
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 密码验证(查询数据库存储的密码)
|
||||
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'`)
|
||||
|
|
@ -29,24 +38,25 @@ export async function POST(request: NextRequest) {
|
|||
return NextResponse.json({ error: 'localadmin 未配置' }, { status: 401 })
|
||||
}
|
||||
const localadminUser = users[0]
|
||||
// 与数据库存储的 bcrypt 哈希比较
|
||||
if (!bcrypt.compareSync(password, localadminUser.password_hash)) {
|
||||
writeAuditLog({ exec: dbExec }, {
|
||||
// 与数据库存储的 bcrypt 哈希比较(异步避免阻塞事件循环)
|
||||
if (!await bcrypt.compare(password, localadminUser.password_hash)) {
|
||||
writeAuditLog({
|
||||
username, action: 'login_failed', entityType: 'auth',
|
||||
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 })
|
||||
}
|
||||
|
||||
resetRateLimit(rateLimitKey)
|
||||
const displayName = localadminUser.display_name || 'localadmin'
|
||||
const role = localadminUser.role || 'admin'
|
||||
const token = signJwt({ secret: authConfig.jwtSecret, payload: { username: 'localadmin', role, displayName } })
|
||||
|
||||
writeAuditLog({ exec: dbExec }, {
|
||||
writeAuditLog({
|
||||
username, action: 'login', entityType: 'auth',
|
||||
details: { method: 'localadmin' },
|
||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||
ipAddress: ip,
|
||||
})
|
||||
|
||||
const response = NextResponse.json({
|
||||
|
|
@ -65,22 +75,24 @@ export async function POST(request: NextRequest) {
|
|||
)
|
||||
|
||||
if (!result.success) {
|
||||
writeAuditLog({ exec: dbExec }, {
|
||||
writeAuditLog({
|
||||
username, action: 'login_failed', entityType: 'auth',
|
||||
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 })
|
||||
}
|
||||
|
||||
resetRateLimit(rateLimitKey)
|
||||
|
||||
// 签发 JWT
|
||||
const users = dbQuery(`SELECT role FROM users WHERE username = ${escapeSql(username)}`)
|
||||
const role = users.length > 0 ? users[0].role as string : 'viewer'
|
||||
|
||||
writeAuditLog({ exec: dbExec }, {
|
||||
writeAuditLog({
|
||||
username, action: 'login', entityType: 'auth',
|
||||
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 } })
|
||||
|
|
|
|||
|
|
@ -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))`)
|
||||
const bcrypt = require('bcryptjs')
|
||||
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, "''")}')`)
|
||||
const raw = execFileSync('sqlite3', ['-json', DB_PATH, 'SELECT id FROM alert_channels'], { encoding: 'utf-8', timeout: 10000 }).trim()
|
||||
const channels = raw ? JSON.parse(raw) : []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue