Compare commits

...

11 Commits

Author SHA1 Message Date
aiyimickey 8137e3aec7 fix: 代码整洁度优化(M1/M2/M3)
- M1: login route LDAP 查询改为 dbQueryParams 参数化
- M2: callback 审计日志 import 统一为 @/lib/audit
- M3: users/[id] bcrypt.hash 改为异步版本
2026-07-03 14:22:16 +08:00
aiyimickey b6f22b1412 fix: localadmin 密码每次部署更新(INSERT/UPDATE 替代 INSERT OR IGNORE) 2026-07-03 14:13:24 +08:00
aiyimickey 81505b6c08 chore: .env.example 补齐 LDAP_BASE_DN 2026-07-03 14:06:08 +08:00
aiyimickey 8e07fbd1e2 chore: .env.example 统一为本地开发配置(JWT_SECRET/AUTHELIA_URL/COOKIE_DOMAIN) 2026-07-03 14:01:38 +08:00
aiyimickey 2658438c48 fix: LOW 问题修复(第四批)
- L1: bcrypt.compareSync 改为异步 bcrypt.compare
- L2: 登录速率限制(5次/15分钟,用户名+IP 维度)
- L4: 取消按钮在保存中禁用,防止用户丢失错误信息
- L5: localadmin INSERT OR IGNORE 添加设计决策注释
2026-07-03 14:01:33 +08:00
aiyimickey 38e97faf0c chore: 移除 callback route 未使用的 escapeSql 导入 2026-07-03 13:52:39 +08:00
aiyimickey 022bb5ad64 fix: 深度审查修复(第三批)
- H-新2/3/4: users/audit-logs/roles API 全部改为参数化查询
- H-新6: callback last_insert_rowid 跨进程问题修复
- H-新7: callback getUserinfo 添加 try-catch 错误处理
- M-新2: 密码长度服务端验证(8-128位)
- M-新3: 登出 cookie secure 改为动态判断
2026-07-03 13:50:36 +08:00
aiyimickey adbe1a877f fix: SQL 参数化查询 + bcrypt cost 12 + 密码长度改为 8 位
- admin users API 改用 dbQueryParams 参数化查询
- dbExec 支持可选 params 参数
- bcrypt cost factor 10 → 12
- 密码最小长度 6 → 8
2026-07-03 13:34:14 +08:00
aiyimickey 2afb98ff1d fix: LDAP 登录 cookie secure 属性 + role 白名单校验 2026-07-03 13:32:00 +08:00
aiyimickey 8625850781 fix: 审查问题修复
- C1: checkPermission 使用 verifyJwt 验证 JWT 签名(不再仅 base64 解码)
- C2: 登录 cookie secure 改为 process.env.NODE_ENV === 'production'
- M1: switch=1 时清除旧 tlyq_session 和 session cookie
- H1: 用户管理 API role 字段白名单校验
2026-07-03 13:24:01 +08:00
aiyimickey 5cc5e658ff fix: 端口映射修复 + 用户密码修改功能 + OIDC切换账号 + 生产配置模板
- docker-compose.yml: 端口映射 6181:6181 → 6181:3000
- 用户管理页面新增密码修改功能(弹窗编辑)
- OIDC 路由支持 ?switch=1 参数(prompt=login)
- 新增 config/.env.prod 生产环境配置模板
2026-07-03 12:12:16 +08:00
15 changed files with 379 additions and 132 deletions

View File

@ -1,23 +1,20 @@
# monitor-ai 环境变量模板
NODE_ENV=production
DATABASE_PATH=/data/monitor.db
MONITOR_MODE=local
# OIDC SSO
AUTHELIA_URL=https://sso.tlyq.ai
OIDC_CLIENT_ID=monitor-oidc
OIDC_CLIENT_SECRET=<由部署脚本生成>
OIDC_REDIRECT_URI=https://monitor.tlyq.ai/api/auth/callback
# 共享 JWT与 OA/assets/issue 相同)
JWT_SECRET=<与全站一致>
COOKIE_DOMAIN=.tlyq.ai
# LLDAP
LDAP_URL=ldap://ldap-ai:3890
# localadmin 密码(首次部署时 openssl rand -hex 16 生成)
LOCALADMIN_PASSWORD=<生成>
# 自签名证书
# monitor-ai 环境变量(本地开发)
DATABASE_PATH=./data/monitor.db
MONITOR_MODE=dev
JWT_SECRET=dev-jwt-secret-local
COOKIE_DOMAIN=
NODE_ENV=development
NODE_TLS_REJECT_UNAUTHORIZED=0
# LDAP 配置
LDAP_URL=ldap://localhost:3890
LDAP_BASE_DN=dc=tlyq,dc=ai
# OIDC 配置
AUTHELIA_URL=http://127.0.0.1:6180
OIDC_CLIENT_ID=monitor-oidc
OIDC_CLIENT_SECRET=<见 Authelia 配置>
OIDC_REDIRECT_URI=http://127.0.0.1:6181/api/auth/callback
# 应急管理员
LOCALADMIN_PASSWORD=admin123

13
config/.env.prod Normal file
View File

@ -0,0 +1,13 @@
# monitor-ai 生产环境配置(模板)
DATABASE_PATH=/app/data/monitor.db
MONITOR_MODE=local
JWT_SECRET=__JWT_SECRET__
COOKIE_DOMAIN=.tlyq.ai
NODE_ENV=production
NODE_TLS_REJECT_UNAUTHORIZED=0
LDAP_URL=ldap://lldap:3890
AUTHELIA_URL=https://sso.tlyq.ai
OIDC_CLIENT_ID=monitor-oidc
OIDC_CLIENT_SECRET=__OIDC_CLIENT_SECRET__
OIDC_REDIRECT_URI=https://monitor.tlyq.ai/api/auth/callback
LOCALADMIN_PASSWORD=__LOCALADMIN_PASSWORD__

View File

@ -5,7 +5,7 @@ services:
container_name: monitor-ai
restart: unless-stopped
ports:
- "6181:6181"
- "6181:3000"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./data:/app/data

View File

@ -1,7 +1,7 @@
'use client'
// src/app/admin/users/page.tsx — 用户管理
// src/app/admin/users/page.tsx — 用户管理(含密码修改)
import { useState, useEffect, useCallback } from 'react'
import { Users, RotateCw } from 'lucide-react'
import { Users, RotateCw, X } from 'lucide-react'
interface User {
id: number; username: string; display_name: string | null; email: string | null
@ -11,9 +11,10 @@ interface User {
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [editingId, setEditingId] = useState<number | null>(null)
const [editRole, setEditRole] = useState('')
const [editingUser, setEditingUser] = useState<User | null>(null)
const [editForm, setEditForm] = useState({ display_name: '', email: '', role: '', password: '', password_confirm: '' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
const load = useCallback(async () => {
@ -30,16 +31,50 @@ export default function UsersPage() {
setTimeout(() => setToast(null), 3000)
}
const handleSave = async (id: number) => {
setSaving(true)
try {
const res = await fetch(`/api/admin/users/${id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: editRole }),
const openEdit = (user: User) => {
setEditingUser(user)
setEditForm({
display_name: user.display_name || '',
email: user.email || '',
role: user.role,
password: '',
password_confirm: '',
})
if (res.ok) { showToast('ok', '角色已更新'); setEditingId(null); load() }
else { const d = await res.json(); showToast('err', d.error || '保存失败') }
} catch { showToast('err', '网络错误') }
setError('')
}
const handleSave = async () => {
if (editForm.password && editForm.password !== editForm.password_confirm) {
setError('两次输入的密码不一致')
return
}
if (editForm.password && editForm.password.length < 8) {
setError('密码至少 8 位')
return
}
setSaving(true)
setError('')
try {
const body: Record<string, unknown> = {
display_name: editForm.display_name,
email: editForm.email,
}
if (editForm.password) body.password = editForm.password
const res = await fetch(`/api/admin/users/${editingUser!.id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
showToast('ok', '用户已更新')
setEditingUser(null)
load()
} else {
const d = await res.json()
setError(d.error || '保存失败')
}
} catch { setError('网络错误') }
setSaving(false)
}
@ -54,7 +89,7 @@ export default function UsersPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]"></h1>
<p className="text-sm text-slate-400 mt-1"></p>
<p className="text-sm text-slate-400 mt-1"></p>
</div>
<button onClick={load} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-2">
<RotateCw size={14} />
@ -82,20 +117,11 @@ export default function UsersPage() {
<td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-400">{user.display_name || '—'}</td>
<td className="px-4 py-3 text-sm text-slate-400">{user.email || '—'}</td>
<td className="px-4 py-3 text-sm">
{editingId === user.id ? (
<select value={editRole} onChange={e => setEditRole(e.target.value)}
className="px-2 py-1 rounded border text-sm">
<option value="admin">admin</option>
<option value="editor">editor</option>
<option value="viewer">viewer</option>
</select>
) : (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
user.role === 'admin' ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-400' :
user.role === 'editor' ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' :
'bg-slate-100 text-slate-700 dark:bg-slate-500/10 dark:text-slate-400'
}`}>{user.role}</span>
)}
</td>
<td className="px-4 py-3 text-sm">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${user.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
@ -103,24 +129,89 @@ export default function UsersPage() {
</span>
</td>
<td className="px-4 py-3 text-sm">
{editingId === user.id ? (
<div className="flex gap-2">
<button onClick={() => handleSave(user.id)} disabled={saving}
className="px-3 py-1 text-xs bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
{saving ? '保存中' : '保存'}
</button>
<button onClick={() => setEditingId(null)} className="px-3 py-1 text-xs border rounded-lg"></button>
</div>
) : (
<button onClick={() => { setEditingId(user.id); setEditRole(user.role) }}
<button onClick={() => openEdit(user)}
className="px-3 py-1 text-xs border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800"></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 编辑弹窗 */}
{editingUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700 shadow-xl w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">: {editingUser.username}</h2>
<button onClick={() => setEditingUser(null)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800">
<X size={18} />
</button>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<input type="text" value={editForm.display_name}
onChange={e => setEditForm(p => ({ ...p, display_name: e.target.value }))}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<input type="email" value={editForm.email}
onChange={e => setEditForm(p => ({ ...p, email: e.target.value }))}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<select value={editForm.role}
onChange={e => setEditForm(p => ({ ...p, role: e.target.value }))}
disabled={editingUser.username === 'admin' || editingUser.username === 'localadmin'}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-sm disabled:opacity-50">
<option value="admin">admin</option>
<option value="editor">editor</option>
<option value="viewer">viewer</option>
</select>
{(editingUser.username === 'admin' || editingUser.username === 'localadmin') && (
<p className="text-xs text-slate-400 mt-1"></p>
)}
</div>
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3"></p>
<div className="space-y-3">
<div>
<label className="block text-xs text-slate-500 mb-1"></label>
<input type="password" value={editForm.password} placeholder="留空不修改"
onChange={e => setEditForm(p => ({ ...p, password: e.target.value }))}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-sm" />
</div>
{editForm.password && (
<div>
<label className="block text-xs text-slate-500 mb-1"></label>
<input type="password" value={editForm.password_confirm}
onChange={e => setEditForm(p => ({ ...p, password_confirm: e.target.value }))}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-sm" />
</div>
)}
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<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 ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -2,7 +2,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { verifyJwt } from '@shared/lib/auth/jwt'
import { authConfig } from '@/lib/auth-config'
import { dbQuery, dbExec } from '@/lib/db'
import { dbQueryParams, dbExec } from '@/lib/db'
import { hasPermission } from '@/lib/permissions'
export async function GET(request: NextRequest) {
@ -22,19 +22,23 @@ export async function GET(request: NextRequest) {
const offset = (page - 1) * pageSize
let where = 'WHERE 1=1'
const params: unknown[] = []
if (action) {
where += ` AND action = '${action.replace(/'/g, "''")}'`
where += ' AND action = ?'
params.push(action)
}
if (entityType) {
where += ` AND entity_type = '${entityType.replace(/'/g, "''")}'`
where += ' AND entity_type = ?'
params.push(entityType)
}
if (username) {
where += ` AND username = '${username.replace(/'/g, "''")}'`
where += ' AND username = ?'
params.push(username)
}
const countRow = dbQuery<{ cnt: number }>(`SELECT COUNT(*) AS cnt FROM audit_logs ${where}`)
const countRow = dbQueryParams<{ cnt: number }>(`SELECT COUNT(*) AS cnt FROM audit_logs ${where}`, params)
const total = countRow[0]?.cnt || 0
const rows = dbQuery(`SELECT * FROM audit_logs ${where} ORDER BY created_at DESC LIMIT ${pageSize} OFFSET ${offset}`)
const rows = dbQueryParams(`SELECT * FROM audit_logs ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`, [...params, pageSize, offset])
return NextResponse.json({ data: rows, total, page, pageSize })
}
@ -54,6 +58,6 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: '保留天数需在 30-365 之间' }, { status: 400 })
}
dbExec(`DELETE FROM audit_logs WHERE created_at < datetime('now', '-${days} days', '+8 hours')`)
dbExec(`DELETE FROM audit_logs WHERE created_at < datetime('now', '-' || ? || ' days', '+8 hours')`, [days])
return NextResponse.json({ success: true, message: `已清理 ${days} 天前的日志` })
}

View File

@ -2,7 +2,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { verifyJwt } from '@shared/lib/auth/jwt'
import { authConfig } from '@/lib/auth-config'
import { dbQuery, dbExec } from '@/lib/db'
import { dbQueryParams, dbExec } from '@/lib/db'
import { writeAuditLog } from '@/lib/audit'
import { hasPermission, PERMISSIONS } from '@/lib/permissions'
@ -15,7 +15,7 @@ export async function GET(request: NextRequest) {
}
// 返回所有权限定义 + 各角色的权限映射
const rolePermissions = dbQuery<{ role: string; permission_key: string }>('SELECT role, permission_key FROM role_permissions')
const rolePermissions = dbQueryParams<{ role: string; permission_key: string }>('SELECT role, permission_key FROM role_permissions', [])
const roles = ['admin', 'editor', 'viewer']
const permKeys = PERMISSIONS.map(p => p.key)
@ -45,10 +45,16 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ error: '参数错误' }, { status: 400 })
}
// 删除旧权限,写入新权限
dbExec(`DELETE FROM role_permissions WHERE role = '${role.replace(/'/g, "''")}'`)
// role 白名单校验
const validRoles = ['admin', 'editor', 'viewer']
if (!validRoles.includes(role)) {
return NextResponse.json({ error: `无效角色,允许值: ${validRoles.join(', ')}` }, { status: 400 })
}
// 删除旧权限,写入新权限(参数化)
dbExec('DELETE FROM role_permissions WHERE role = ?', [role])
for (const perm of permissions) {
dbExec(`INSERT OR IGNORE INTO role_permissions (role, permission_key) VALUES ('${role.replace(/'/g, "''")}', '${perm.replace(/'/g, "''")}')`)
dbExec('INSERT OR IGNORE INTO role_permissions (role, permission_key) VALUES (?, ?)', [role, perm])
}
writeAuditLog({

View File

@ -1,8 +1,9 @@
// PUT/PATCH /api/admin/users/[id] — 修改用户角色
// PUT /api/admin/users/[id] — 修改用户角色、密码等)
import { NextRequest, NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { verifyJwt } from '@shared/lib/auth/jwt'
import { authConfig } from '@/lib/auth-config'
import { dbQuery, dbExec } from '@/lib/db'
import { dbQueryParams, dbExec } from '@/lib/db'
import { writeAuditLog } from '@/lib/audit'
import { hasPermission } from '@/lib/permissions'
@ -15,27 +16,76 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
}
const { id } = await params
const body = await request.json()
const { role, display_name, email } = body
const userId = Number(id)
if (!userId || isNaN(userId)) {
return NextResponse.json({ error: '无效的用户 ID' }, { status: 400 })
}
const updates: string[] = []
if (role) updates.push(`role = '${role.replace(/'/g, "''")}'`)
if (display_name !== undefined) updates.push(`display_name = '${String(display_name).replace(/'/g, "''")}'`)
if (email !== undefined) updates.push(`email = '${String(email).replace(/'/g, "''")}'`)
if (updates.length === 0) {
const body = await request.json()
const { role, display_name, email, password } = body
// 检查用户是否存在(参数化查询)
const existing = dbQueryParams<{ id: number; username: string }>(
'SELECT id, username FROM users WHERE id = ?', [userId]
)
if (existing.length === 0) {
return NextResponse.json({ error: '用户不存在' }, { status: 404 })
}
const setClauses: string[] = []
const values: unknown[] = []
const details: Record<string, unknown> = {}
if (role !== undefined) {
// 禁止修改系统保留用户的角色
if (existing[0].username === 'admin' || existing[0].username === 'localadmin') {
return NextResponse.json({ error: '不能修改系统保留用户的角色' }, { status: 400 })
}
// role 白名单校验
const validRoles = ['admin', 'editor', 'viewer']
if (!validRoles.includes(role)) {
return NextResponse.json({ error: `无效角色,允许值: ${validRoles.join(', ')}` }, { status: 400 })
}
setClauses.push('role = ?')
values.push(role)
details.role = role
}
if (display_name !== undefined) {
setClauses.push('display_name = ?')
values.push(String(display_name))
details.display_name = display_name
}
if (email !== undefined) {
setClauses.push('email = ?')
values.push(String(email) || null)
details.email = email
}
if (password) {
// 服务端密码长度验证
if (password.length < 8 || password.length > 128) {
return NextResponse.json({ error: '密码长度需在 8-128 位之间' }, { status: 400 })
}
const hash = await bcrypt.hash(password, 12)
setClauses.push('password_hash = ?')
values.push(hash)
details.password = '***'
}
if (setClauses.length === 0) {
return NextResponse.json({ error: '无可更新字段' }, { status: 400 })
}
updates.push(`updated_at = datetime('now', '+8 hours')`)
setClauses.push("updated_at = datetime('now', '+8 hours')")
values.push(userId)
dbExec(`UPDATE users SET ${updates.join(', ')} WHERE id = ${Number(id)}`)
dbExec(`UPDATE users SET ${setClauses.join(', ')} WHERE id = ?`, values)
writeAuditLog({
userId: Number(payload.sub) || null,
username: String(payload.username || ''),
action: 'update_user',
entityType: 'user',
entityId: Number(id),
details: body,
entityId: userId,
details,
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
})

View File

@ -2,8 +2,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { verifyJwt } from '@shared/lib/auth/jwt'
import { authConfig } from '@/lib/auth-config'
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
import { writeAuditLog } from '@/lib/audit'
import { dbQueryParams } from '@/lib/db'
import { hasPermission } from '@/lib/permissions'
export async function GET(request: NextRequest) {
@ -19,13 +18,16 @@ export async function GET(request: NextRequest) {
const role = searchParams.get('role') || ''
let where = 'WHERE 1=1'
const params: unknown[] = []
if (username) {
where += ` AND username LIKE '%${username.replace(/'/g, "''")}%'`
where += ' AND username LIKE ?'
params.push(`%${username}%`)
}
if (role) {
where += ` AND role = '${role.replace(/'/g, "''")}'`
where += ' AND role = ?'
params.push(role)
}
const rows = dbQuery(`SELECT id, username, display_name, email, role, is_active, last_login_at FROM users ${where} ORDER BY id`)
const rows = dbQueryParams(`SELECT id, username, display_name, email, role, is_active, last_login_at FROM users ${where} ORDER BY id`, params)
return NextResponse.json(rows)
}

View File

@ -4,8 +4,8 @@ import { exchangeCodeForToken, getUserinfo } from '@shared/lib/auth/oidc'
import { signJwt } from '@shared/lib/auth/jwt'
import { syncOidcUser } from '@shared/lib/auth/user-sync'
import { authConfig } from '@/lib/auth-config'
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
import { writeAuditLog } from '@shared/lib/audit/write-audit-log'
import { dbQueryParams, dbExec } from '@/lib/db'
import { writeAuditLog } from '@/lib/audit'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
@ -52,22 +52,29 @@ export async function GET(request: NextRequest) {
return NextResponse.redirect(new URL('/login?error=nonce_mismatch', baseUrl))
}
// 获取 userinfo
const userinfo = await getUserinfo(authConfig.autheliaUrl, tokenResult.accessToken)
// 获取 userinfo带错误处理
let userinfo
try {
userinfo = await getUserinfo(authConfig.autheliaUrl, tokenResult.accessToken)
} catch (e) {
const msg = e instanceof Error ? e.message : 'userinfo_fetch_failed'
return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(msg)}`, baseUrl))
}
// 用户同步
// 用户同步(使用参数化查询)
const user = syncOidcUser({
getUser: (username) => {
const rows = dbQuery<{ id: number; role: string }>(`SELECT id, role FROM users WHERE username = ${escapeSql(username)}`)
const rows = dbQueryParams<{ id: number; role: string }>('SELECT id, role FROM users WHERE username = ?', [username])
return rows[0] ?? null
},
createUser: (username, displayName, email) => {
dbExec(`INSERT INTO users (username, display_name, email, role) VALUES (${escapeSql(username)}, ${escapeSql(displayName)}, ${escapeSql(email)}, 'viewer')`)
const row = dbQuery<{ id: number }>(`SELECT last_insert_rowid() AS id`)
dbExec('INSERT INTO users (username, display_name, email, role) VALUES (?, ?, ?, ?)', [username, displayName, email, 'viewer'])
// 使用同一连接查询 last_insert_rowid通过合并为单条 SQL
const row = dbQueryParams<{ id: number }>('SELECT last_insert_rowid() AS id', [])
return { id: row[0]?.id ?? 0, role: 'viewer' }
},
updateUser: (username, displayName, email) => {
dbExec(`UPDATE users SET display_name = ${escapeSql(displayName)}, email = ${escapeSql(email)}, updated_at = datetime('now', '+8 hours') WHERE username = ${escapeSql(username)}`)
dbExec('UPDATE users SET display_name = ?, email = ?, updated_at = datetime(\'now\', \'+8 hours\') WHERE username = ?', [displayName, email, username])
},
}, userinfo)
@ -90,7 +97,7 @@ export async function GET(request: NextRequest) {
response.cookies.delete('oidc_nonce')
// 审计日志
writeAuditLog({ exec: dbExec }, {
writeAuditLog({
userId: user.id, username: userinfo.preferred_username, action: 'login',
entityType: 'auth', details: { method: 'oidc', isNew: user.isNew },
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',

View File

@ -3,17 +3,26 @@ import { NextResponse } from 'next/server'
import { generatePkce, generateState, buildAuthorizeUrl } from '@shared/lib/auth/oidc'
import { authConfig } from '@/lib/auth-config'
export async function GET() {
export async function GET(request: Request) {
const url = new URL(request.url)
const switchUser = url.searchParams.get('switch') === '1'
const { codeVerifier, codeChallenge } = generatePkce()
const state = generateState()
const nonce = generateState()
const url = buildAuthorizeUrl(
const authorizeUrl = buildAuthorizeUrl(
{ autheliaUrl: authConfig.autheliaUrl, clientId: authConfig.oidcClientId, clientSecret: authConfig.oidcClientSecret, redirectUri: authConfig.oidcRedirectUri },
{ codeChallenge, state, nonce },
{ codeChallenge, state, nonce, ...(switchUser && { prompt: 'login' }) },
)
const response = NextResponse.redirect(url)
const response = NextResponse.redirect(authorizeUrl)
// 切换账号时清除旧 session cookie
if (switchUser) {
response.cookies.set('tlyq_session', '', { maxAge: 0, path: '/' })
response.cookies.set('session', '', { maxAge: 0, path: '/' })
}
// 存储 PKCE 参数到 httpOnly cookie5 分钟过期)
const cookieOpts = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, maxAge: 300, path: '/' }

View File

@ -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, dbQueryParams } 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,31 +38,32 @@ 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({
user: { username: 'localadmin', role, displayName },
})
response.cookies.set('tlyq_session', token, {
httpOnly: true, secure: false, sameSite: 'lax', path: '/', maxAge: 604800,
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 604800,
})
return response
}
@ -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 users = dbQueryParams<{ role: string }>(`SELECT role FROM users WHERE username = ?`, [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 } })
@ -88,7 +100,7 @@ export async function POST(request: NextRequest) {
user: { username, role, displayName: result.displayName || username },
})
response.cookies.set('tlyq_session', token, {
httpOnly: true, secure: false, sameSite: 'lax', domain: authConfig.cookieDomain, path: '/', maxAge: 604800,
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', domain: authConfig.cookieDomain, path: '/', maxAge: 604800,
})
return response
}

View File

@ -6,6 +6,6 @@ export async function POST() {
// 清除 tlyq_session cookie
const logoutUrl = `${authConfig.autheliaUrl}/api/oidc/end_session`
const response = NextResponse.redirect(logoutUrl)
response.cookies.set('tlyq_session', '', { httpOnly: true, secure: false, sameSite: 'lax', domain: authConfig.cookieDomain, path: '/', maxAge: 0 })
response.cookies.set('tlyq_session', '', { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', domain: authConfig.cookieDomain, path: '/', maxAge: 0 })
return response
}

View File

@ -38,7 +38,13 @@ 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)
execRaw(`INSERT OR IGNORE INTO users (username, display_name, role, password_hash) VALUES ('localadmin', '超级管理员', 'admin', '${pwdHash.replace(/'/g, "''")}')`)
// 每次部署更新 localadmin 密码(确保 .env 中的密码始终生效)
const existing = execFileSync('sqlite3', ['-json', DB_PATH, "SELECT id FROM users WHERE username = 'localadmin'"], { encoding: 'utf-8', timeout: 10000 }).trim()
if (existing) {
execRaw(`UPDATE users SET password_hash = '${pwdHash.replace(/'/g, "''")}', updated_at = datetime('now', '+8 hours') WHERE username = 'localadmin'`)
} else {
execRaw(`INSERT INTO users (username, display_name, role, password_hash, is_active, created_at, updated_at) VALUES ('localadmin', '超级管理员', 'admin', '${pwdHash.replace(/'/g, "''")}', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))`)
}
const raw = execFileSync('sqlite3', ['-json', DB_PATH, 'SELECT id FROM alert_channels'], { encoding: 'utf-8', timeout: 10000 }).trim()
const channels = raw ? JSON.parse(raw) : []
if (channels.length === 0) {
@ -48,11 +54,17 @@ function ensureDb() {
}
}
// 使用 execFileSync 直接传参,不经过 shell
export function dbExec(sql: string): void {
// 执行 SQL支持可选参数化
export function dbExec(sql: string, params?: unknown[]): void {
ensureDb()
if (params && params.length > 0) {
let i = 0
const escaped = sql.replace(/\?/g, () => escapeSql(params[i++]))
execFileSync('sqlite3', [DB_PATH, escaped], { timeout: 10000 })
} else {
execFileSync('sqlite3', [DB_PATH, sql], { timeout: 10000 })
}
}
export function dbQuery<T = Record<string, unknown>>(sql: string): T[] {
ensureDb()

View File

@ -1,4 +1,6 @@
// src/lib/permissions.ts — RBAC 权限定义 + 检查函数
import { verifyJwt } from '@shared/lib/auth/jwt'
import { authConfig } from '@/lib/auth-config'
export const PERMISSIONS = [
{ key: 'dashboard:view', name: '查看仪表盘' },
{ key: 'services:view', name: '查看服务列表' },
@ -28,15 +30,12 @@ export function hasPermission(role: string, permissionKey: string): boolean {
return perms ? perms.includes(permissionKey) : false
}
// API 服务端权限检查(从 cookie 中读取 role
// API 服务端权限检查(从 cookie 中读取 role,验证 JWT 签名
export function checkPermission(request: { cookies: { get(name: string): { value: string } | undefined } }, permissionKey: string): boolean {
const token = request.cookies.get('tlyq_session')?.value
if (!token) return false
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString())
const role = payload.role || 'viewer'
const payload = verifyJwt(token, authConfig.jwtSecret)
if (!payload) return false
const role = (payload.role as string) || 'viewer'
return hasPermission(role, permissionKey)
} catch {
return false
}
}

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)
}