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>
|
||||||
|
|
||||||
<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 ? '保存中...' : '保存'}
|
||||||
|
|
|
||||||
|
|
@ -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 } })
|
||||||
|
|
|
||||||
|
|
@ -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) : []
|
||||||
|
|
|
||||||
|
|
@ -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