Compare commits
No commits in common. "main" and "v2026.07.02" have entirely different histories.
main
...
v2026.07.0
37
.env.example
37
.env.example
|
|
@ -1,20 +1,23 @@
|
||||||
# monitor-ai 环境变量(本地开发)
|
# monitor-ai 环境变量模板
|
||||||
DATABASE_PATH=./data/monitor.db
|
NODE_ENV=production
|
||||||
MONITOR_MODE=dev
|
DATABASE_PATH=/data/monitor.db
|
||||||
JWT_SECRET=dev-jwt-secret-local
|
MONITOR_MODE=local
|
||||||
COOKIE_DOMAIN=
|
|
||||||
NODE_ENV=development
|
|
||||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
|
||||||
|
|
||||||
# LDAP 配置
|
# OIDC SSO
|
||||||
LDAP_URL=ldap://localhost:3890
|
AUTHELIA_URL=https://sso.tlyq.ai
|
||||||
LDAP_BASE_DN=dc=tlyq,dc=ai
|
|
||||||
|
|
||||||
# OIDC 配置
|
|
||||||
AUTHELIA_URL=http://127.0.0.1:6180
|
|
||||||
OIDC_CLIENT_ID=monitor-oidc
|
OIDC_CLIENT_ID=monitor-oidc
|
||||||
OIDC_CLIENT_SECRET=<见 Authelia 配置>
|
OIDC_CLIENT_SECRET=<由部署脚本生成>
|
||||||
OIDC_REDIRECT_URI=http://127.0.0.1:6181/api/auth/callback
|
OIDC_REDIRECT_URI=https://monitor.tlyq.ai/api/auth/callback
|
||||||
|
|
||||||
# 应急管理员
|
# 共享 JWT(与 OA/assets/issue 相同)
|
||||||
LOCALADMIN_PASSWORD=admin123
|
JWT_SECRET=<与全站一致>
|
||||||
|
COOKIE_DOMAIN=.tlyq.ai
|
||||||
|
|
||||||
|
# LLDAP
|
||||||
|
LDAP_URL=ldap://ldap-ai:3890
|
||||||
|
|
||||||
|
# localadmin 密码(首次部署时 openssl rand -hex 16 生成)
|
||||||
|
LOCALADMIN_PASSWORD=<生成>
|
||||||
|
|
||||||
|
# 自签名证书
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,3 @@ data/*.db
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.DS_Store
|
.DS_Store
|
||||||
tsconfig.tsbuildinfo
|
|
||||||
|
|
|
||||||
14
CLAUDE.md
14
CLAUDE.md
|
|
@ -261,13 +261,6 @@ monitor-ai/
|
||||||
| GET | `/api/status-history` | 登录 | 状态变更历史(分页,可按 service_id 筛选) |
|
| GET | `/api/status-history` | 登录 | 状态变更历史(分页,可按 service_id 筛选) |
|
||||||
| GET | `/api/status` | 登录 | 所有服务实时状态汇总 |
|
| GET | `/api/status` | 登录 | 所有服务实时状态汇总 |
|
||||||
|
|
||||||
### 内部 API(x-internal-key 鉴权)
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| GET | `/api/internal/users` | 返回用户列表 |
|
|
||||||
| POST | `/api/internal/users` | OA 同步用户角色 |
|
|
||||||
|
|
||||||
### 管理
|
### 管理
|
||||||
|
|
||||||
| 方法 | 路径 | 权限 | 说明 |
|
| 方法 | 路径 | 权限 | 说明 |
|
||||||
|
|
@ -366,12 +359,11 @@ NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
|
|
||||||
| 库 | 用途 | 导入路径 |
|
| 库 | 用途 | 导入路径 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `@shared/lib/auth/jwt-v2` | JWT 签名/验证(HS256,含 iss) | `import { signJwtV2, verifyJwtV2 } from '@shared/lib/auth/jwt-v2'` |
|
| `@shared/lib/auth/jwt` | JWT 签名/验证(零依赖,Node crypto) | `import { signJwt, verifyJwt } from '@shared/lib/auth/jwt'` |
|
||||||
| `@shared/lib/auth/jwt` | JWT 签名/验证(V1 兼容,无 iss) | `import { signJwt, verifyJwt } from '@shared/lib/auth/jwt'` |
|
|
||||||
| `@shared/lib/auth/middleware-v2` | 路由守卫工厂(V2:单 cookie 模型) | `import { createMiddlewareV2 } from '@shared/lib/auth/middleware-v2'` |
|
|
||||||
| `@shared/lib/auth/middleware` | 路由守卫工厂(V1,已废弃) | `import { createMiddleware } from '@shared/lib/auth/middleware'` |
|
|
||||||
| `@shared/lib/auth/oidc` | OIDC PKCE 流程 | `import { discoverOidcConfig, buildAuthorizeUrl } from '@shared/lib/auth/oidc'` |
|
| `@shared/lib/auth/oidc` | OIDC PKCE 流程 | `import { discoverOidcConfig, buildAuthorizeUrl } from '@shared/lib/auth/oidc'` |
|
||||||
| `@shared/lib/auth/ldap` | LDAP 认证 | `import { ldapAuth } from '@shared/lib/auth/ldap'` |
|
| `@shared/lib/auth/ldap` | LDAP 认证 | `import { ldapAuth } from '@shared/lib/auth/ldap'` |
|
||||||
|
| `@shared/lib/auth/middleware` | 路由守卫工厂 | `import { createMiddleware } from '@shared/lib/auth/middleware'` |
|
||||||
|
| `@shared/lib/auth/user-sync` | OIDC 用户同步 | `import { syncOidcUser } from '@shared/lib/auth/user-sync'` |
|
||||||
| `@shared/lib/alert/alert-manager` | 告警决策引擎 | `import { AlertManager } from '@shared/lib/alert/alert-manager'` |
|
| `@shared/lib/alert/alert-manager` | 告警决策引擎 | `import { AlertManager } from '@shared/lib/alert/alert-manager'` |
|
||||||
| `@shared/lib/alert/health-checker` | 健康检查引擎 | `import { HealthChecker, HttpChecker, DockerChecker } from '@shared/lib/alert/health-checker'` |
|
| `@shared/lib/alert/health-checker` | 健康检查引擎 | `import { HealthChecker, HttpChecker, DockerChecker } from '@shared/lib/alert/health-checker'` |
|
||||||
| `@shared/lib/audit/write-audit-log` | 审计日志 | `import { writeAuditLog } from '@shared/lib/audit/write-audit-log'` |
|
| `@shared/lib/audit/write-audit-log` | 审计日志 | `import { writeAuditLog } from '@shared/lib/audit/write-audit-log'` |
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
# 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__
|
|
||||||
|
|
@ -4,18 +4,11 @@ services:
|
||||||
build: .
|
build: .
|
||||||
container_name: monitor-ai
|
container_name: monitor-ai
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 40s
|
|
||||||
ports:
|
ports:
|
||||||
- "6181:3000"
|
- "6181:6181"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./.next:/app/.next
|
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ npx tsx scripts/monitor-worker.ts &
|
||||||
WORKER_PID=$! || true
|
WORKER_PID=$! || true
|
||||||
|
|
||||||
# 启动 Next.js standalone server
|
# 启动 Next.js standalone server
|
||||||
HOSTNAME=0.0.0.0 node server.js &
|
node server.js &
|
||||||
NEXT_PID=$!
|
NEXT_PID=$!
|
||||||
|
|
||||||
echo "[entrypoint] Worker PID: $WORKER_PID, Next.js PID: $NEXT_PID"
|
echo "[entrypoint] Worker PID: $WORKER_PID, Next.js PID: $NEXT_PID"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { NextConfig } from 'next'
|
||||||
|
|
||||||
const config: NextConfig = {
|
const config: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
transpilePackages: ['lucide-react', 'ldapts'],
|
transpilePackages: ['lucide-react'],
|
||||||
}
|
}
|
||||||
|
|
||||||
export default config
|
export default config
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
// src/app/admin/layout.tsx — V2 admin 路径鉴权(Layer 2:独立验签 + DB role 检查)
|
|
||||||
// V2 middleware 不检查 role,由 layout/API 层自行鉴权
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import { verifyJwtV2, extractIss } from '@shared/lib/auth/jwt-v2'
|
|
||||||
import { authConfig } from '@/lib/auth-config'
|
|
||||||
import { dbQueryParams } from '@/lib/db'
|
|
||||||
|
|
||||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('tlyq_session')?.value
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return <Forbidden />
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取 iss → 自引用验签
|
|
||||||
const iss = extractIss(token)
|
|
||||||
if (!iss) {
|
|
||||||
return <Forbidden />
|
|
||||||
}
|
|
||||||
|
|
||||||
const jwtSecret = authConfig.jwtSecret
|
|
||||||
const payload = verifyJwtV2(token, jwtSecret, iss)
|
|
||||||
if (!payload) {
|
|
||||||
return <Forbidden />
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从本地 DB 查询 role
|
|
||||||
const rows = dbQueryParams<{ role: string }>(
|
|
||||||
'SELECT role FROM users WHERE username = ? AND is_active = 1',
|
|
||||||
[payload.username]
|
|
||||||
)
|
|
||||||
const role = rows[0]?.role || 'viewer'
|
|
||||||
|
|
||||||
if (role !== 'admin') {
|
|
||||||
return <Forbidden />
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
function Forbidden() {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '60vh' }}>
|
|
||||||
<p style={{ color: '#999', fontSize: 18 }}>Forbidden</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
// src/app/admin/users/page.tsx — 用户管理(含密码修改)
|
// src/app/admin/users/page.tsx — 用户管理
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Users, RotateCw, X } from 'lucide-react'
|
import { Users, RotateCw } from 'lucide-react'
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number; username: string; display_name: string | null; email: string | null
|
id: number; username: string; display_name: string | null; email: string | null
|
||||||
|
|
@ -11,10 +11,9 @@ interface User {
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const [users, setUsers] = useState<User[]>([])
|
const [users, setUsers] = useState<User[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
const [editForm, setEditForm] = useState({ display_name: '', email: '', role: '', password: '', password_confirm: '' })
|
const [editRole, setEditRole] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
|
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
|
@ -31,50 +30,16 @@ export default function UsersPage() {
|
||||||
setTimeout(() => setToast(null), 3000)
|
setTimeout(() => setToast(null), 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEdit = (user: User) => {
|
const handleSave = async (id: number) => {
|
||||||
setEditingUser(user)
|
|
||||||
setEditForm({
|
|
||||||
display_name: user.display_name || '',
|
|
||||||
email: user.email || '',
|
|
||||||
role: user.role,
|
|
||||||
password: '',
|
|
||||||
password_confirm: '',
|
|
||||||
})
|
|
||||||
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)
|
setSaving(true)
|
||||||
setError('')
|
|
||||||
try {
|
try {
|
||||||
const body: Record<string, unknown> = {
|
const res = await fetch(`/api/admin/users/${id}`, {
|
||||||
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' },
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify({ role: editRole }),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) { showToast('ok', '角色已更新'); setEditingId(null); load() }
|
||||||
showToast('ok', '用户已更新')
|
else { const d = await res.json(); showToast('err', d.error || '保存失败') }
|
||||||
setEditingUser(null)
|
} catch { showToast('err', '网络错误') }
|
||||||
load()
|
|
||||||
} else {
|
|
||||||
const d = await res.json()
|
|
||||||
setError(d.error || '保存失败')
|
|
||||||
}
|
|
||||||
} catch { setError('网络错误') }
|
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +54,7 @@ export default function UsersPage() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">用户管理</h1>
|
<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>
|
</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">
|
<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} /> 刷新
|
<RotateCw size={14} /> 刷新
|
||||||
|
|
@ -117,11 +82,20 @@ 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-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 text-slate-400">{user.email || '—'}</td>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
{editingId === user.id ? (
|
||||||
user.role === 'admin' ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-400' :
|
<select value={editRole} onChange={e => setEditRole(e.target.value)}
|
||||||
user.role === 'editor' ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' :
|
className="px-2 py-1 rounded border text-sm">
|
||||||
'bg-slate-100 text-slate-700 dark:bg-slate-500/10 dark:text-slate-400'
|
<option value="admin">admin</option>
|
||||||
}`}>{user.role}</span>
|
<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>
|
||||||
<td className="px-4 py-3 text-sm">
|
<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'}`}>
|
<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'}`}>
|
||||||
|
|
@ -129,89 +103,24 @@ export default function UsersPage() {
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
<button onClick={() => openEdit(user)}
|
{editingId === user.id ? (
|
||||||
className="px-3 py-1 text-xs border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800">编辑</button>
|
<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) }}
|
||||||
|
className="px-3 py-1 text-xs border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800">编辑</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,14 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||||
import { authConfig } from '@/lib/auth-config'
|
import { authConfig } from '@/lib/auth-config'
|
||||||
import { dbQueryParams, dbExec } from '@/lib/db'
|
import { dbQuery, dbExec } from '@/lib/db'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getRole } from '@/lib/get-role'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
if (!payload || !hasPermission(getRole(payload), 'audit:view')) {
|
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'audit:view')) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -23,23 +22,19 @@ export async function GET(request: NextRequest) {
|
||||||
const offset = (page - 1) * pageSize
|
const offset = (page - 1) * pageSize
|
||||||
|
|
||||||
let where = 'WHERE 1=1'
|
let where = 'WHERE 1=1'
|
||||||
const params: unknown[] = []
|
|
||||||
if (action) {
|
if (action) {
|
||||||
where += ' AND action = ?'
|
where += ` AND action = '${action.replace(/'/g, "''")}'`
|
||||||
params.push(action)
|
|
||||||
}
|
}
|
||||||
if (entityType) {
|
if (entityType) {
|
||||||
where += ' AND entity_type = ?'
|
where += ` AND entity_type = '${entityType.replace(/'/g, "''")}'`
|
||||||
params.push(entityType)
|
|
||||||
}
|
}
|
||||||
if (username) {
|
if (username) {
|
||||||
where += ' AND username = ?'
|
where += ` AND username = '${username.replace(/'/g, "''")}'`
|
||||||
params.push(username)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const countRow = dbQueryParams<{ cnt: number }>(`SELECT COUNT(*) AS cnt FROM audit_logs ${where}`, params)
|
const countRow = dbQuery<{ cnt: number }>(`SELECT COUNT(*) AS cnt FROM audit_logs ${where}`)
|
||||||
const total = countRow[0]?.cnt || 0
|
const total = countRow[0]?.cnt || 0
|
||||||
const rows = dbQueryParams(`SELECT * FROM audit_logs ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`, [...params, pageSize, offset])
|
const rows = dbQuery(`SELECT * FROM audit_logs ${where} ORDER BY created_at DESC LIMIT ${pageSize} OFFSET ${offset}`)
|
||||||
|
|
||||||
return NextResponse.json({ data: rows, total, page, pageSize })
|
return NextResponse.json({ data: rows, total, page, pageSize })
|
||||||
}
|
}
|
||||||
|
|
@ -49,7 +44,7 @@ export async function DELETE(request: NextRequest) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
if (!payload || !hasPermission(getRole(payload), 'audit:view')) {
|
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'audit:view')) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,6 +54,6 @@ export async function DELETE(request: NextRequest) {
|
||||||
return NextResponse.json({ error: '保留天数需在 30-365 之间' }, { status: 400 })
|
return NextResponse.json({ error: '保留天数需在 30-365 之间' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
dbExec(`DELETE FROM audit_logs WHERE created_at < datetime('now', '-' || ? || ' days', '+8 hours')`, [days])
|
dbExec(`DELETE FROM audit_logs WHERE created_at < datetime('now', '-${days} days', '+8 hours')`)
|
||||||
return NextResponse.json({ success: true, message: `已清理 ${days} 天前的日志` })
|
return NextResponse.json({ success: true, message: `已清理 ${days} 天前的日志` })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,20 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||||
import { authConfig } from '@/lib/auth-config'
|
import { authConfig } from '@/lib/auth-config'
|
||||||
import { dbQueryParams, dbExec } from '@/lib/db'
|
import { dbQuery, dbExec } from '@/lib/db'
|
||||||
import { writeAuditLog } from '@/lib/audit'
|
import { writeAuditLog } from '@/lib/audit'
|
||||||
import { hasPermission, PERMISSIONS } from '@/lib/permissions'
|
import { hasPermission, PERMISSIONS } from '@/lib/permissions'
|
||||||
import { getRole } from '@/lib/get-role'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
if (!payload || !hasPermission(getRole(payload), 'roles:manage')) {
|
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'roles:manage')) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回所有权限定义 + 各角色的权限映射
|
// 返回所有权限定义 + 各角色的权限映射
|
||||||
const rolePermissions = dbQueryParams<{ role: string; permission_key: string }>('SELECT role, permission_key FROM role_permissions', [])
|
const rolePermissions = dbQuery<{ role: string; permission_key: string }>('SELECT role, permission_key FROM role_permissions')
|
||||||
const roles = ['admin', 'editor', 'viewer']
|
const roles = ['admin', 'editor', 'viewer']
|
||||||
const permKeys = PERMISSIONS.map(p => p.key)
|
const permKeys = PERMISSIONS.map(p => p.key)
|
||||||
|
|
||||||
|
|
@ -35,7 +34,7 @@ export async function PUT(request: NextRequest) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
if (!payload || !hasPermission(getRole(payload), 'roles:manage')) {
|
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'roles:manage')) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,16 +45,10 @@ export async function PUT(request: NextRequest) {
|
||||||
return NextResponse.json({ error: '参数错误' }, { status: 400 })
|
return NextResponse.json({ error: '参数错误' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// role 白名单校验
|
// 删除旧权限,写入新权限
|
||||||
const validRoles = ['admin', 'editor', 'viewer']
|
dbExec(`DELETE FROM role_permissions WHERE role = '${role.replace(/'/g, "''")}'`)
|
||||||
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) {
|
for (const perm of permissions) {
|
||||||
dbExec('INSERT OR IGNORE INTO role_permissions (role, permission_key) VALUES (?, ?)', [role, perm])
|
dbExec(`INSERT OR IGNORE INTO role_permissions (role, permission_key) VALUES ('${role.replace(/'/g, "''")}', '${perm.replace(/'/g, "''")}')`)
|
||||||
}
|
}
|
||||||
|
|
||||||
writeAuditLog({
|
writeAuditLog({
|
||||||
|
|
|
||||||
|
|
@ -1,92 +1,41 @@
|
||||||
// PUT /api/admin/users/[id] — 修改用户(角色、密码等)
|
// PUT/PATCH /api/admin/users/[id] — 修改用户角色
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import bcrypt from 'bcryptjs'
|
|
||||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||||
import { authConfig } from '@/lib/auth-config'
|
import { authConfig } from '@/lib/auth-config'
|
||||||
import { dbQueryParams, dbExec } from '@/lib/db'
|
import { dbQuery, dbExec } from '@/lib/db'
|
||||||
import { writeAuditLog } from '@/lib/audit'
|
import { writeAuditLog } from '@/lib/audit'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getRole } from '@/lib/get-role'
|
|
||||||
|
|
||||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
if (!payload || !hasPermission(getRole(payload), 'users:manage')) {
|
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'users:manage')) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const userId = Number(id)
|
|
||||||
if (!userId || isNaN(userId)) {
|
|
||||||
return NextResponse.json({ error: '无效的用户 ID' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { role, display_name, email, password } = body
|
const { role, display_name, email } = body
|
||||||
|
|
||||||
// 检查用户是否存在(参数化查询)
|
const updates: string[] = []
|
||||||
const existing = dbQueryParams<{ id: number; username: string }>(
|
if (role) updates.push(`role = '${role.replace(/'/g, "''")}'`)
|
||||||
'SELECT id, username FROM users WHERE id = ?', [userId]
|
if (display_name !== undefined) updates.push(`display_name = '${String(display_name).replace(/'/g, "''")}'`)
|
||||||
)
|
if (email !== undefined) updates.push(`email = '${String(email).replace(/'/g, "''")}'`)
|
||||||
if (existing.length === 0) {
|
if (updates.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 })
|
return NextResponse.json({ error: '无可更新字段' }, { status: 400 })
|
||||||
}
|
}
|
||||||
setClauses.push("updated_at = datetime('now', '+8 hours')")
|
updates.push(`updated_at = datetime('now', '+8 hours')`)
|
||||||
values.push(userId)
|
|
||||||
|
|
||||||
dbExec(`UPDATE users SET ${setClauses.join(', ')} WHERE id = ?`, values)
|
dbExec(`UPDATE users SET ${updates.join(', ')} WHERE id = ${Number(id)}`)
|
||||||
|
|
||||||
writeAuditLog({
|
writeAuditLog({
|
||||||
userId: Number(payload.sub) || null,
|
userId: Number(payload.sub) || null,
|
||||||
username: String(payload.username || ''),
|
username: String(payload.username || ''),
|
||||||
action: 'update_user',
|
action: 'update_user',
|
||||||
entityType: 'user',
|
entityType: 'user',
|
||||||
entityId: userId,
|
entityId: Number(id),
|
||||||
details,
|
details: body,
|
||||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,15 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||||
import { authConfig } from '@/lib/auth-config'
|
import { authConfig } from '@/lib/auth-config'
|
||||||
import { dbQueryParams } from '@/lib/db'
|
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||||
|
import { writeAuditLog } from '@/lib/audit'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getRole } from '@/lib/get-role'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
if (!payload || !hasPermission(getRole(payload), 'users:view')) {
|
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'users:view')) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,16 +19,13 @@ export async function GET(request: NextRequest) {
|
||||||
const role = searchParams.get('role') || ''
|
const role = searchParams.get('role') || ''
|
||||||
|
|
||||||
let where = 'WHERE 1=1'
|
let where = 'WHERE 1=1'
|
||||||
const params: unknown[] = []
|
|
||||||
if (username) {
|
if (username) {
|
||||||
where += ' AND username LIKE ?'
|
where += ` AND username LIKE '%${username.replace(/'/g, "''")}%'`
|
||||||
params.push(`%${username}%`)
|
|
||||||
}
|
}
|
||||||
if (role) {
|
if (role) {
|
||||||
where += ' AND role = ?'
|
where += ` AND role = '${role.replace(/'/g, "''")}'`
|
||||||
params.push(role)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = dbQueryParams(`SELECT id, username, display_name, email, role, is_active, last_login_at FROM users ${where} ORDER BY id`, params)
|
const rows = dbQuery(`SELECT id, username, display_name, email, role, is_active, last_login_at FROM users ${where} ORDER BY id`)
|
||||||
return NextResponse.json(rows)
|
return NextResponse.json(rows)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,12 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||||
import { authConfig } from '@/lib/auth-config'
|
import { authConfig } from '@/lib/auth-config'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getRole } from '@/lib/get-role'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
if (!payload || !hasPermission(getRole(payload), 'dashboard:view')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'dashboard:view')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
return NextResponse.json({ status: 'running', uptime: process.uptime() })
|
return NextResponse.json({ status: 'running', uptime: process.uptime() })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,12 @@ import { authConfig } from '@/lib/auth-config'
|
||||||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||||
import { writeAuditLog } from '@/lib/audit'
|
import { writeAuditLog } from '@/lib/audit'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getRole } from '@/lib/get-role'
|
|
||||||
|
|
||||||
async function checkAdmin(request: NextRequest) {
|
async function checkAdmin(request: NextRequest) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
if (!token) return null
|
if (!token) return null
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
if (!payload || !hasPermission(getRole(payload), 'alerts:manage')) return null
|
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'alerts:manage')) return null
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,12 @@ import { authConfig } from '@/lib/auth-config'
|
||||||
import { dbQuery } from '@/lib/db'
|
import { dbQuery } from '@/lib/db'
|
||||||
import { WeChatPusher } from '@shared/lib/wechat/wechat-pusher'
|
import { WeChatPusher } from '@shared/lib/wechat/wechat-pusher'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getRole } from '@/lib/get-role'
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
if (!payload || !hasPermission(getRole(payload), 'alerts:manage')) {
|
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'alerts:manage')) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,12 @@ import { authConfig } from '@/lib/auth-config'
|
||||||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||||
import { writeAuditLog } from '@/lib/audit'
|
import { writeAuditLog } from '@/lib/audit'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getRole } from '@/lib/get-role'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
if (!payload || !hasPermission(getRole(payload), 'alerts:manage')) {
|
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'alerts:manage')) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
const channels = dbQuery('SELECT id, name, channel_type, webhook_url, enabled, level_critical, level_warning, level_info, quiet_enabled, quiet_start, quiet_end, quiet_bypass_critical, cooldown_minutes, created_at, updated_at FROM alert_channels ORDER BY id')
|
const channels = dbQuery('SELECT id, name, channel_type, webhook_url, enabled, level_critical, level_warning, level_info, quiet_enabled, quiet_start, quiet_end, quiet_bypass_critical, cooldown_minutes, created_at, updated_at FROM alert_channels ORDER BY id')
|
||||||
|
|
@ -22,7 +21,7 @@ export async function POST(request: NextRequest) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
if (!payload || !hasPermission(getRole(payload), 'alerts:manage')) {
|
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'alerts:manage')) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,12 @@ import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||||
import { authConfig } from '@/lib/auth-config'
|
import { authConfig } from '@/lib/auth-config'
|
||||||
import { dbQuery } from '@/lib/db'
|
import { dbQuery } from '@/lib/db'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getRole } from '@/lib/get-role'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
if (!payload || !hasPermission(getRole(payload), 'alerts:view')) {
|
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'alerts:view')) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,100 @@
|
||||||
// GET /api/auth/callback — OIDC callback(V2:使用 shared handleOidcCallback 工厂)
|
// GET /api/auth/callback — OIDC callback 处理
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { handleOidcCallback } from '@shared/lib/auth/handle-callback'
|
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 { authConfig } from '@/lib/auth-config'
|
||||||
import { dbQueryParams, dbExec } from '@/lib/db'
|
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||||
import { writeAuditLog } from '@/lib/audit'
|
import { writeAuditLog } from '@shared/lib/audit/write-audit-log'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
return handleOidcCallback(request, {
|
const { searchParams } = new URL(request.url)
|
||||||
oidc: {
|
const code = searchParams.get('code')
|
||||||
autheliaUrl: authConfig.autheliaUrl,
|
const state = searchParams.get('state')
|
||||||
clientId: authConfig.oidcClientId,
|
const error = searchParams.get('error')
|
||||||
clientSecret: authConfig.oidcClientSecret,
|
|
||||||
redirectUri: authConfig.oidcRedirectUri,
|
|
||||||
},
|
|
||||||
jwtSecret: authConfig.jwtSecret,
|
|
||||||
cookieDomain: authConfig.cookieDomain,
|
|
||||||
|
|
||||||
|
// 使用 OIDC_REDIRECT_URI 构造公共 URL(LESSONS-LEARNED #16)
|
||||||
|
const baseUrl = authConfig.oidcRedirectUri?.replace(/\/api\/auth\/callback.*/, '') || 'http://localhost:6181'
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(error)}`, baseUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
return NextResponse.redirect(new URL('/login?error=missing_params', baseUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 state
|
||||||
|
const savedState = request.cookies.get('oidc_state')?.value
|
||||||
|
if (!savedState || savedState !== state) {
|
||||||
|
return NextResponse.redirect(new URL('/login?error=state_mismatch', baseUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取出 code_verifier
|
||||||
|
const codeVerifier = request.cookies.get('oidc_code_verifier')?.value
|
||||||
|
if (!codeVerifier) {
|
||||||
|
return NextResponse.redirect(new URL('/login?error=missing_verifier', baseUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 换取 token
|
||||||
|
const tokenResult = await exchangeCodeForToken(
|
||||||
|
{ autheliaUrl: authConfig.autheliaUrl, clientId: authConfig.oidcClientId, clientSecret: authConfig.oidcClientSecret, redirectUri: authConfig.oidcRedirectUri },
|
||||||
|
code, codeVerifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!tokenResult.success) {
|
||||||
|
return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(tokenResult.error || 'token_exchange_failed')}`, baseUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 nonce
|
||||||
|
const savedNonce = request.cookies.get('oidc_nonce')?.value
|
||||||
|
if (savedNonce && tokenResult.nonce && savedNonce !== tokenResult.nonce) {
|
||||||
|
return NextResponse.redirect(new URL('/login?error=nonce_mismatch', baseUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 userinfo
|
||||||
|
const userinfo = await getUserinfo(authConfig.autheliaUrl, tokenResult.accessToken)
|
||||||
|
|
||||||
|
// 用户同步
|
||||||
|
const user = syncOidcUser({
|
||||||
getUser: (username) => {
|
getUser: (username) => {
|
||||||
const rows = dbQueryParams<{ id: number; role: string }>(
|
const rows = dbQuery<{ id: number; role: string }>(`SELECT id, role FROM users WHERE username = ${escapeSql(username)}`)
|
||||||
'SELECT id, role FROM users WHERE username = ? AND is_active = 1', [username]
|
|
||||||
)
|
|
||||||
return rows[0] ?? null
|
return rows[0] ?? null
|
||||||
},
|
},
|
||||||
|
|
||||||
createUser: (username, displayName, email) => {
|
createUser: (username, displayName, email) => {
|
||||||
dbExec(
|
dbExec(`INSERT INTO users (username, display_name, email, role) VALUES (${escapeSql(username)}, ${escapeSql(displayName)}, ${escapeSql(email)}, 'viewer')`)
|
||||||
'INSERT INTO users (username, display_name, email, role) VALUES (?, ?, ?, ?)',
|
const row = dbQuery<{ id: number }>(`SELECT last_insert_rowid() AS id`)
|
||||||
[username, displayName, email, 'viewer']
|
|
||||||
)
|
|
||||||
const row = dbQueryParams<{ id: number }>('SELECT last_insert_rowid() AS id', [])
|
|
||||||
return { id: row[0]?.id ?? 0, role: 'viewer' }
|
return { id: row[0]?.id ?? 0, role: 'viewer' }
|
||||||
},
|
},
|
||||||
|
|
||||||
updateUser: (username, displayName, email) => {
|
updateUser: (username, displayName, email) => {
|
||||||
dbExec(
|
dbExec(`UPDATE users SET display_name = ${escapeSql(displayName)}, email = ${escapeSql(email)}, updated_at = datetime('now', '+8 hours') WHERE username = ${escapeSql(username)}`)
|
||||||
"UPDATE users SET display_name = ?, email = ?, updated_at = datetime('now', '+8 hours') WHERE username = ?",
|
|
||||||
[displayName, email, username]
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
|
}, userinfo)
|
||||||
|
|
||||||
onAuditLog: (userId, username, req) => {
|
// 签发 JWT
|
||||||
writeAuditLog({
|
const token = signJwt({
|
||||||
userId,
|
secret: authConfig.jwtSecret,
|
||||||
username,
|
payload: { username: userinfo.preferred_username, displayName: userinfo.name, role: user.role },
|
||||||
action: 'login',
|
|
||||||
entityType: 'auth',
|
|
||||||
details: { method: 'oidc' },
|
|
||||||
ipAddress: req.headers.get('x-forwarded-for') || '127.0.0.1',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const response = NextResponse.redirect(new URL('/dashboard', baseUrl))
|
||||||
|
|
||||||
|
// 签发 tlyq_session cookie
|
||||||
|
response.cookies.set('tlyq_session', token, {
|
||||||
|
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', domain: process.env.NODE_ENV === 'production' ? authConfig.cookieDomain : undefined, path: '/', maxAge: 604800,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 清理 OIDC 临时 cookie
|
||||||
|
response.cookies.delete('oidc_code_verifier')
|
||||||
|
response.cookies.delete('oidc_state')
|
||||||
|
response.cookies.delete('oidc_nonce')
|
||||||
|
|
||||||
|
// 审计日志
|
||||||
|
writeAuditLog({ exec: dbExec }, {
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,25 @@
|
||||||
// GET /api/auth/login/oidc — OIDC SSO 重定向(V2:使用 shared handleOidcLogin 工厂)
|
// GET /api/auth/login/oidc — OIDC SSO 重定向
|
||||||
import { handleOidcLogin } from '@shared/lib/auth/handle-login'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { generatePkce, generateState, buildAuthorizeUrl } from '@shared/lib/auth/oidc'
|
||||||
import { authConfig } from '@/lib/auth-config'
|
import { authConfig } from '@/lib/auth-config'
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET() {
|
||||||
const url = new URL(request.url)
|
const { codeVerifier, codeChallenge } = generatePkce()
|
||||||
const switchUser = url.searchParams.get('switch') === '1'
|
const state = generateState()
|
||||||
|
const nonce = generateState()
|
||||||
|
|
||||||
return handleOidcLogin({
|
const url = buildAuthorizeUrl(
|
||||||
autheliaUrl: authConfig.autheliaUrl,
|
{ autheliaUrl: authConfig.autheliaUrl, clientId: authConfig.oidcClientId, clientSecret: authConfig.oidcClientSecret, redirectUri: authConfig.oidcRedirectUri },
|
||||||
clientId: authConfig.oidcClientId,
|
{ codeChallenge, state, nonce },
|
||||||
clientSecret: authConfig.oidcClientSecret,
|
)
|
||||||
redirectUri: authConfig.oidcRedirectUri,
|
|
||||||
switchUser,
|
const response = NextResponse.redirect(url)
|
||||||
})
|
|
||||||
|
// 存储 PKCE 参数到 httpOnly cookie(5 分钟过期)
|
||||||
|
const cookieOpts = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, maxAge: 300, path: '/' }
|
||||||
|
response.cookies.set('oidc_code_verifier', codeVerifier, cookieOpts)
|
||||||
|
response.cookies.set('oidc_state', state, cookieOpts)
|
||||||
|
response.cookies.set('oidc_nonce', nonce, cookieOpts)
|
||||||
|
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
// POST /api/auth/login — LDAP 本地登录(回退通道)
|
// POST /api/auth/login — LDAP 本地登录(回退通道)
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import { signJwtV2 } from '@shared/lib/auth/jwt-v2'
|
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, dbQueryParams } from '@/lib/db'
|
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||||
import { writeAuditLog } from '@/lib/audit'
|
import { writeAuditLog } from '@shared/lib/audit/write-audit-log'
|
||||||
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
|
||||||
|
|
@ -23,14 +22,6 @@ 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'`)
|
||||||
|
|
@ -38,32 +29,31 @@ 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 (!await bcrypt.compare(password, localadminUser.password_hash)) {
|
if (!bcrypt.compareSync(password, localadminUser.password_hash)) {
|
||||||
writeAuditLog({
|
writeAuditLog({ exec: dbExec }, {
|
||||||
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: ip,
|
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||||
})
|
})
|
||||||
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 = signJwtV2({ secret: authConfig.jwtSecret, iss: 'monitor.tlyq.ai', payload: { username: 'localadmin', displayName } })
|
const token = signJwt({ secret: authConfig.jwtSecret, payload: { username: 'localadmin', role, displayName } })
|
||||||
|
|
||||||
writeAuditLog({
|
writeAuditLog({ exec: dbExec }, {
|
||||||
username, action: 'login', entityType: 'auth',
|
username, action: 'login', entityType: 'auth',
|
||||||
details: { method: 'localadmin' },
|
details: { method: 'localadmin' },
|
||||||
ipAddress: ip,
|
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
user: { username: 'localadmin', role, displayName },
|
user: { username: 'localadmin', role, displayName },
|
||||||
})
|
})
|
||||||
response.cookies.set('tlyq_session', token, {
|
response.cookies.set('tlyq_session', token, {
|
||||||
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 604800,
|
httpOnly: true, secure: false, sameSite: 'lax', path: '/', maxAge: 604800,
|
||||||
})
|
})
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
@ -75,32 +65,30 @@ export async function POST(request: NextRequest) {
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
writeAuditLog({
|
writeAuditLog({ exec: dbExec }, {
|
||||||
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: ip,
|
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||||
})
|
})
|
||||||
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
resetRateLimit(rateLimitKey)
|
|
||||||
|
|
||||||
// 签发 JWT
|
// 签发 JWT
|
||||||
const users = dbQueryParams<{ role: string }>(`SELECT role FROM users WHERE username = ?`, [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({
|
writeAuditLog({ exec: dbExec }, {
|
||||||
username, action: 'login', entityType: 'auth',
|
username, action: 'login', entityType: 'auth',
|
||||||
details: { method: 'ldap', role },
|
details: { method: 'ldap', role },
|
||||||
ipAddress: ip,
|
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||||
})
|
})
|
||||||
|
|
||||||
const token = signJwtV2({ secret: authConfig.jwtSecret, iss: 'monitor.tlyq.ai', payload: { username, displayName: result.displayName || username } })
|
const token = signJwt({ secret: authConfig.jwtSecret, payload: { username, role, displayName: result.displayName || username } })
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
user: { username, role, displayName: result.displayName || username },
|
user: { username, role, displayName: result.displayName || username },
|
||||||
})
|
})
|
||||||
response.cookies.set('tlyq_session', token, {
|
response.cookies.set('tlyq_session', token, {
|
||||||
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', domain: authConfig.cookieDomain, path: '/', maxAge: 604800,
|
httpOnly: true, secure: false, sameSite: 'lax', domain: authConfig.cookieDomain, path: '/', maxAge: 604800,
|
||||||
})
|
})
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,11 @@
|
||||||
// GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login)
|
// POST /api/auth/logout — 退出登录
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { authConfig } from '@/lib/auth-config'
|
||||||
|
|
||||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
export async function POST() {
|
||||||
|
// 清除 tlyq_session cookie
|
||||||
/** 从 OIDC_REDIRECT_URI 提取 site URL(不可信请求头,见 LESSONS-LEARNED #51) */
|
const logoutUrl = `${authConfig.autheliaUrl}/api/oidc/end_session`
|
||||||
function getSiteUrl(): string {
|
const response = NextResponse.redirect(logoutUrl)
|
||||||
const redirectUri = process.env.OIDC_REDIRECT_URI || ''
|
response.cookies.set('tlyq_session', '', { httpOnly: true, secure: false, sameSite: 'lax', domain: authConfig.cookieDomain, path: '/', maxAge: 0 })
|
||||||
try { const u = new URL(redirectUri); return `${u.protocol}//${u.host}` } catch { /* fallthrough */ }
|
|
||||||
return process.env.NEXT_PUBLIC_SITE_URL || 'https://monitor.tlyq.ai'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清除 tlyq_session + session + oidc_id_token cookie → 302 跳转 /login */
|
|
||||||
function logoutResponse(): NextResponse {
|
|
||||||
const response = NextResponse.redirect(new URL('/login', getSiteUrl()))
|
|
||||||
response.cookies.set('tlyq_session', '', {
|
|
||||||
httpOnly: true, secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
|
|
||||||
})
|
|
||||||
response.cookies.set('session', '', { path: '/', maxAge: 0 })
|
|
||||||
response.cookies.set('oidc_id_token', '', {
|
|
||||||
httpOnly: true, secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
|
|
||||||
})
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function GET() { return logoutResponse() }
|
|
||||||
export async function POST() { return logoutResponse() }
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
// GET /api/auth/me — 当前用户信息(V2:JWT 不含 role,从本地 DB 查询)
|
// GET /api/auth/me — 当前用户信息
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { verifyJwtV2, extractIss } from '@shared/lib/auth/jwt-v2'
|
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||||
import { authConfig } from '@/lib/auth-config'
|
import { authConfig } from '@/lib/auth-config'
|
||||||
import { dbQueryParams } from '@/lib/db'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
|
|
@ -10,24 +9,16 @@ export async function GET(request: NextRequest) {
|
||||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layer 3 defense-in-depth:提取 iss → 自引用验签
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
const iss = extractIss(token) || '*'
|
|
||||||
const payload = verifyJwtV2(token, authConfig.jwtSecret, iss)
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
|
return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = payload.username as string
|
|
||||||
|
|
||||||
// 从本地 DB 查询角色(V2 JWT 不含 role,认证与鉴权分离)
|
|
||||||
const rows = dbQueryParams<{ role: string; display_name: string }>(
|
|
||||||
'SELECT role, display_name FROM users WHERE username = ? AND is_active = 1',
|
|
||||||
[username]
|
|
||||||
)
|
|
||||||
const role = rows[0]?.role || 'viewer'
|
|
||||||
const displayName = rows[0]?.display_name || (payload.displayName as string) || username
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
user: { username, display_name: displayName, role },
|
user: {
|
||||||
|
username: payload.username,
|
||||||
|
display_name: payload.displayName || payload.username,
|
||||||
|
role: payload.role || 'viewer',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
// GET /api/health — monitor-ai 自身健康检查
|
// GET /api/health — monitor-ai 自身健康检查
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const d = new Date()
|
return Response.json({ status: 'OK', timestamp: new Date().toISOString() })
|
||||||
const timestamp = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}+08:00`
|
|
||||||
return Response.json({ status: 'OK', timestamp })
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
// GET /api/internal/roles — 供 OA 查询 monitor 支持的角色列表
|
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { ROLE_DEFAULT_PERMISSIONS, PERMISSIONS } from '@/lib/permissions'
|
|
||||||
|
|
||||||
const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const key = request.headers.get('x-internal-key')
|
|
||||||
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
|
|
||||||
const roles = Object.keys(ROLE_DEFAULT_PERMISSIONS).map(name => ({
|
|
||||||
name,
|
|
||||||
permissions: ROLE_DEFAULT_PERMISSIONS[name].map(k => PERMISSIONS.find(p => p.key === k)?.name || k),
|
|
||||||
}))
|
|
||||||
|
|
||||||
return NextResponse.json({ roles })
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
// GET /api/internal/users — 供 OA 查询 monitor 用户列表
|
|
||||||
// POST /api/internal/users — 供 OA 同步用户角色
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { execFileSync } from 'child_process'
|
|
||||||
|
|
||||||
const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
|
|
||||||
const DB_PATH = process.env.DATABASE_PATH || '/app/data/monitor.db'
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const key = request.headers.get('x-internal-key')
|
|
||||||
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
|
|
||||||
try {
|
|
||||||
const out = execFileSync('sqlite3', [DB_PATH, 'SELECT username, display_name, role FROM users WHERE is_active = 1 ORDER BY username'], { timeout: 3000, encoding: 'utf8' }).trim()
|
|
||||||
const users = out.split('\n').filter(Boolean).map(line => {
|
|
||||||
const [username, display_name, role] = line.split('|')
|
|
||||||
return { username, display_name: display_name || username, role }
|
|
||||||
})
|
|
||||||
return NextResponse.json({ users })
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ users: [] })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const key = request.headers.get('x-internal-key')
|
|
||||||
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
|
|
||||||
const body = await request.json()
|
|
||||||
const { username, displayName, role } = body
|
|
||||||
if (!username) {
|
|
||||||
return NextResponse.json({ error: 'username 必填' }, { status: 400 })
|
|
||||||
}
|
|
||||||
const VALID_ROLES = ['admin', 'editor', 'viewer']
|
|
||||||
const safeRole = VALID_ROLES.includes(role) ? role : 'viewer'
|
|
||||||
|
|
||||||
try {
|
|
||||||
execFileSync('sqlite3', [DB_PATH], {
|
|
||||||
input: `INSERT INTO users (username, display_name, role, is_active, created_at, updated_at)
|
|
||||||
VALUES ('${username.replace(/'/g, "''")}', '${(displayName || username).replace(/'/g, "''")}', '${safeRole}', 1,
|
|
||||||
datetime('now', '+8 hours'), datetime('now', '+8 hours'))
|
|
||||||
ON CONFLICT(username) DO UPDATE SET role = '${safeRole}',
|
|
||||||
display_name = '${(displayName || username).replace(/'/g, "''")}',
|
|
||||||
updated_at = datetime('now', '+8 hours');`,
|
|
||||||
timeout: 3000,
|
|
||||||
})
|
|
||||||
return NextResponse.json({ success: true })
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: '同步失败' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,14 +5,13 @@ import { authConfig } from '@/lib/auth-config'
|
||||||
import { dbQuery } from '@/lib/db'
|
import { dbQuery } from '@/lib/db'
|
||||||
import { HttpChecker, DockerChecker, HealthChecker } from '@shared/lib/alert/health-checker'
|
import { HttpChecker, DockerChecker, HealthChecker } from '@shared/lib/alert/health-checker'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getRole } from '@/lib/get-role'
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
if (!payload) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!payload) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
if (!hasPermission(getRole(payload), 'services:manage')) {
|
if (!hasPermission(String(payload.role || 'viewer'), 'services:manage')) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,13 @@ import { authConfig } from '@/lib/auth-config'
|
||||||
import { dbQuery } from '@/lib/db'
|
import { dbQuery } from '@/lib/db'
|
||||||
import { writeAuditLog } from '@/lib/audit'
|
import { writeAuditLog } from '@/lib/audit'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getRole } from '@/lib/get-role'
|
|
||||||
import { containerOp, type ContainerAction } from '@/lib/container-ops'
|
import { containerOp, type ContainerAction } from '@/lib/container-ops'
|
||||||
|
|
||||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
if (!payload || !hasPermission(getRole(payload), 'services:manage')) {
|
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'services:manage')) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,13 @@ import { authConfig } from '@/lib/auth-config'
|
||||||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||||
import { writeAuditLog } from '@/lib/audit'
|
import { writeAuditLog } from '@/lib/audit'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getRole } from '@/lib/get-role'
|
|
||||||
|
|
||||||
async function checkAdmin(request: NextRequest) {
|
async function checkAdmin(request: NextRequest) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
if (!token) return null
|
if (!token) return null
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
if (!payload) return null
|
if (!payload) return null
|
||||||
if (!hasPermission(getRole(payload), 'services:manage')) return null
|
if (!hasPermission(String(payload.role || 'viewer'), 'services:manage')) return null
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,8 @@ import { authConfig } from '@/lib/auth-config'
|
||||||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||||
import { writeAuditLog } from '@/lib/audit'
|
import { writeAuditLog } from '@/lib/audit'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getRole } from '@/lib/get-role'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
|
||||||
if (!payload) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
if (!hasPermission(getRole(payload), 'services:view')) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
const services = dbQuery(`SELECT * FROM services WHERE enabled = 1 ORDER BY display_order, id`)
|
const services = dbQuery(`SELECT * FROM services WHERE enabled = 1 ORDER BY display_order, id`)
|
||||||
// 解析 JSON checks 字段
|
// 解析 JSON checks 字段
|
||||||
const result = services.map(s => ({ ...s, checks: JSON.parse(String(s.checks || '[]')) }))
|
const result = services.map(s => ({ ...s, checks: JSON.parse(String(s.checks || '[]')) }))
|
||||||
|
|
@ -28,7 +19,7 @@ export async function POST(request: NextRequest) {
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
if (!payload) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!payload) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
if (!hasPermission(getRole(payload), 'services:manage')) {
|
if (!hasPermission(String(payload.role || 'viewer'), 'services:manage')) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,8 @@
|
||||||
// GET /api/status-history — 状态变更列表
|
// GET /api/status-history — 状态变更列表
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
|
||||||
import { authConfig } from '@/lib/auth-config'
|
|
||||||
import { dbQuery } from '@/lib/db'
|
import { dbQuery } from '@/lib/db'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
|
||||||
import { getRole } from '@/lib/get-role'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
|
||||||
if (!payload) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
if (!hasPermission(getRole(payload), 'status_history:view')) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const page = Number(searchParams.get('page')) || 1
|
const page = Number(searchParams.get('page')) || 1
|
||||||
const limit = 20
|
const limit = 20
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,10 @@
|
||||||
// GET /api/status — 所有服务实时状态摘要
|
// GET /api/status — 所有服务实时状态摘要
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
|
||||||
import { authConfig } from '@/lib/auth-config'
|
|
||||||
import { dbQuery } from '@/lib/db'
|
import { dbQuery } from '@/lib/db'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
|
||||||
import { getRole } from '@/lib/get-role'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET() {
|
||||||
const token = request.cookies.get('tlyq_session')?.value
|
|
||||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
|
||||||
if (!payload) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
if (!hasPermission(getRole(payload), 'services:view')) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
const services = dbQuery('SELECT id, name, category, alert_level, current_status, status_since FROM services WHERE enabled = 1 ORDER BY display_order, id')
|
const services = dbQuery('SELECT id, name, category, alert_level, current_status, status_since FROM services WHERE enabled = 1 ORDER BY display_order, id')
|
||||||
const counts = { normal: 0, abnormal: 0, unknown: 0 }
|
const counts = { normal: 0, abnormal: 0, unknown: 0 }
|
||||||
services.forEach((s: Record<string, unknown>) => { const st = String(s.current_status); if (st === 'normal') counts.normal++; else if (st === 'abnormal') counts.abnormal++; else counts.unknown++ })
|
services.forEach((s: Record<string, unknown>) => { const st = String(s.current_status); if (st === 'normal') counts.normal++; else if (st === 'abnormal') counts.abnormal++; else counts.unknown++ })
|
||||||
const d = new Date()
|
return NextResponse.json({ services, counts, timestamp: new Date().toISOString() })
|
||||||
const timestamp = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}+08:00`
|
|
||||||
return NextResponse.json({ services, counts, timestamp })
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,7 @@ 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)
|
||||||
// 每次部署更新 localadmin 密码(确保 .env 中的密码始终生效)
|
execRaw(`INSERT OR IGNORE INTO users (username, display_name, role, password_hash) VALUES ('localadmin', '超级管理员', 'admin', '${pwdHash.replace(/'/g, "''")}')`)
|
||||||
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 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) : []
|
||||||
if (channels.length === 0) {
|
if (channels.length === 0) {
|
||||||
|
|
@ -54,16 +48,10 @@ function ensureDb() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行 SQL(支持可选参数化)
|
// 使用 execFileSync 直接传参,不经过 shell
|
||||||
export function dbExec(sql: string, params?: unknown[]): void {
|
export function dbExec(sql: string): void {
|
||||||
ensureDb()
|
ensureDb()
|
||||||
if (params && params.length > 0) {
|
execFileSync('sqlite3', [DB_PATH, sql], { timeout: 10000 })
|
||||||
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[] {
|
export function dbQuery<T = Record<string, unknown>>(sql: string): T[] {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
// 服务端专用:从 JWT + DB 获取角色和权限(含 child_process 依赖,不可在客户端导入)
|
|
||||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
|
||||||
import { authConfig } from '@/lib/auth-config'
|
|
||||||
import { dbQueryParams } from '@/lib/db'
|
|
||||||
import { hasPermission } from '@/lib/permissions'
|
|
||||||
|
|
||||||
export function getRole(payload: Record<string, unknown> | null): string {
|
|
||||||
if (!payload?.username) return 'viewer'
|
|
||||||
const rows = dbQueryParams<{ role: string }>(
|
|
||||||
'SELECT role FROM users WHERE username = ? AND is_active = 1',
|
|
||||||
[payload.username as string]
|
|
||||||
)
|
|
||||||
return rows[0]?.role || 'viewer'
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
|
||||||
if (!payload) return false
|
|
||||||
return hasPermission(getRole(payload), permissionKey)
|
|
||||||
}
|
|
||||||
|
|
@ -85,8 +85,8 @@ export class MonitorWorker {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 数据清理(每日,UTC+8 时区)
|
// 数据清理(每日,UTC+8 时区)
|
||||||
const d = new Date()
|
const now = new Date()
|
||||||
const today = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
const today = new Date(now.getTime() + 8 * 3600000).toISOString().slice(0, 10)
|
||||||
if (today !== this.lastCleanupDate) {
|
if (today !== this.lastCleanupDate) {
|
||||||
this.cleanupOldData()
|
this.cleanupOldData()
|
||||||
this.lastCleanupDate = today
|
this.lastCleanupDate = today
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// src/lib/permissions.ts — RBAC 权限定义 + 客户端安全函数(不含 DB 依赖)
|
// src/lib/permissions.ts — RBAC 权限定义 + 检查函数
|
||||||
export const PERMISSIONS = [
|
export const PERMISSIONS = [
|
||||||
{ key: 'dashboard:view', name: '查看仪表盘' },
|
{ key: 'dashboard:view', name: '查看仪表盘' },
|
||||||
{ key: 'services:view', name: '查看服务列表' },
|
{ key: 'services:view', name: '查看服务列表' },
|
||||||
|
|
@ -13,17 +13,30 @@ export const PERMISSIONS = [
|
||||||
{ key: 'roles:manage', name: '管理角色权限' },
|
{ key: 'roles:manage', name: '管理角色权限' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
// 角色默认权限映射
|
||||||
export const ROLE_DEFAULT_PERMISSIONS: Record<string, string[]> = {
|
export const ROLE_DEFAULT_PERMISSIONS: Record<string, string[]> = {
|
||||||
admin: PERMISSIONS.map(p => p.key),
|
admin: PERMISSIONS.map(p => p.key),
|
||||||
editor: ['dashboard:view', 'services:view', 'alerts:view', 'status_history:view', 'status_history:stats'],
|
editor: ['dashboard:view', 'services:view', 'alerts:view', 'status_history:view', 'status_history:stats'],
|
||||||
viewer: ['dashboard:view', 'services:view', 'status_history:view'],
|
viewer: ['dashboard:view', 'services:view', 'status_history:view'],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查角色是否拥有某项权限
|
||||||
export function hasPermission(role: string, permissionKey: string): boolean {
|
export function hasPermission(role: string, permissionKey: string): boolean {
|
||||||
if (role === 'localadmin') return true
|
if (role === 'localadmin') return true
|
||||||
if (role === 'admin') return true
|
if (role === 'admin') return true // admin 拥有全部权限
|
||||||
const perms = ROLE_DEFAULT_PERMISSIONS[role]
|
const perms = ROLE_DEFAULT_PERMISSIONS[role]
|
||||||
return perms ? perms.includes(permissionKey) : false
|
return perms ? perms.includes(permissionKey) : false
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkPermission 已移至 @/lib/get-role.ts(服务端专用,API routes 请从 get-role 导入)
|
// API 服务端权限检查(从 cookie 中读取 role)
|
||||||
|
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'
|
||||||
|
return hasPermission(role, permissionKey)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,8 @@
|
||||||
// src/middleware.ts — V2 单 cookie 模型,Edge 验签 + iss 校验
|
// src/middleware.ts — 使用共享 middleware 工厂
|
||||||
import { createMiddlewareV2 } from '@shared/lib/auth/middleware-v2'
|
import { createMiddleware } from '@shared/lib/auth/middleware'
|
||||||
|
|
||||||
const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
|
export const middleware = createMiddleware({
|
||||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
adminPaths: ['/admin', '/services', '/settings', '/alerts'],
|
||||||
|
|
||||||
export const middleware = createMiddlewareV2({
|
|
||||||
jwtSecret,
|
|
||||||
cookieDomain,
|
|
||||||
allowedIssuers: ['*'],
|
|
||||||
enableApiKey: false,
|
|
||||||
publicPaths: ['/login', '/api/auth', '/api/health', '/api/internal', '/_next', '/favicon.ico'],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }
|
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue