Compare commits

...

1 Commits

Author SHA1 Message Date
aiyimickey 676d093568 fix: #26 logout 302 重定向 + API 权限补全 + 内部用户端点 2026-07-15 16:00:23 +08:00
8 changed files with 83 additions and 9 deletions

1
.gitignore vendored
View File

@ -6,3 +6,4 @@ data/*.db
*.log *.log
.DS_Store .DS_Store
.DS_Store .DS_Store
tsconfig.tsbuildinfo

View File

@ -261,6 +261,13 @@ monitor-ai/
| GET | `/api/status-history` | 登录 | 状态变更历史(分页,可按 service_id 筛选) | | GET | `/api/status-history` | 登录 | 状态变更历史(分页,可按 service_id 筛选) |
| GET | `/api/status` | 登录 | 所有服务实时状态汇总 | | GET | `/api/status` | 登录 | 所有服务实时状态汇总 |
### 内部 APIx-internal-key 鉴权)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/internal/users` | 返回用户列表 |
| POST | `/api/internal/users` | OA 同步用户角色 |
### 管理 ### 管理
| 方法 | 路径 | 权限 | 说明 | | 方法 | 路径 | 权限 | 说明 |
@ -359,11 +366,12 @@ NODE_TLS_REJECT_UNAUTHORIZED=0
| 库 | 用途 | 导入路径 | | 库 | 用途 | 导入路径 |
|------|------|------| |------|------|------|
| `@shared/lib/auth/jwt` | JWT 签名/验证零依赖Node crypto | `import { signJwt, verifyJwt } from '@shared/lib/auth/jwt'` | | `@shared/lib/auth/jwt-v2` | JWT 签名/验证HS256含 iss | `import { signJwtV2, verifyJwtV2 } from '@shared/lib/auth/jwt-v2'` |
| `@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'` |

View File

@ -10,7 +10,7 @@ function getSiteUrl(): string {
return process.env.NEXT_PUBLIC_SITE_URL || 'https://monitor.tlyq.ai' return process.env.NEXT_PUBLIC_SITE_URL || 'https://monitor.tlyq.ai'
} }
/** 清除 tlyq_session + session cookie → 302 跳转 /login */ /** 清除 tlyq_session + session + oidc_id_token cookie → 302 跳转 /login */
function logoutResponse(): NextResponse { function logoutResponse(): NextResponse {
const response = NextResponse.redirect(new URL('/login', getSiteUrl())) const response = NextResponse.redirect(new URL('/login', getSiteUrl()))
response.cookies.set('tlyq_session', '', { response.cookies.set('tlyq_session', '', {
@ -18,8 +18,13 @@ function logoutResponse(): NextResponse {
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0, sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
}) })
response.cookies.set('session', '', { 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 GET() { return logoutResponse() }
export async function POST() { return logoutResponse() } export async function POST() { return logoutResponse() }

View File

@ -1,5 +1,6 @@
// GET /api/internal/users — 供 OA 查询 monitor 用户列表 // GET /api/internal/users — 供 OA 查询 monitor 用户列表
import { NextResponse } from 'next/server' // POST /api/internal/users — 供 OA 同步用户角色
import { NextRequest, NextResponse } from 'next/server'
import { execFileSync } from 'child_process' import { execFileSync } from 'child_process'
const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026' const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
@ -20,3 +21,31 @@ export async function GET(request: Request) {
return NextResponse.json({ users: [] }) 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 })
}
}

View File

@ -8,7 +8,15 @@ import { writeAuditLog } from '@/lib/audit'
import { hasPermission } from '@/lib/permissions' import { hasPermission } from '@/lib/permissions'
import { getRole } from '@/lib/get-role' import { getRole } from '@/lib/get-role'
export async function GET() { 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 })
}
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 || '[]')) }))

View File

@ -1,8 +1,20 @@
// 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

View File

@ -1,8 +1,19 @@
// GET /api/status — 所有服务实时状态摘要 // GET /api/status — 所有服务实时状态摘要
import { 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() { 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 })
}
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++ })

File diff suppressed because one or more lines are too long