fix: logout 302 redirect + .next volume mount for hot-deploy

This commit is contained in:
aiyimickey 2026-07-07 17:18:19 +08:00
parent 8137e3aec7
commit 67e8979362
29 changed files with 211 additions and 176 deletions

View File

@ -4,11 +4,18 @@ 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:3000"
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

View File

@ -10,7 +10,7 @@ npx tsx scripts/monitor-worker.ts &
WORKER_PID=$! || true WORKER_PID=$! || true
# 启动 Next.js standalone server # 启动 Next.js standalone server
node server.js & HOSTNAME=0.0.0.0 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"

View File

@ -2,7 +2,7 @@ import type { NextConfig } from 'next'
const config: NextConfig = { const config: NextConfig = {
output: 'standalone', output: 'standalone',
transpilePackages: ['lucide-react'], transpilePackages: ['lucide-react', 'ldapts'],
} }
export default config export default config

48
src/app/admin/layout.tsx Normal file
View File

@ -0,0 +1,48 @@
// 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>
)
}

View File

@ -4,12 +4,13 @@ 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 { dbQueryParams, 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(String(payload.role || 'viewer'), 'audit:view')) { if (!payload || !hasPermission(getRole(payload), 'audit:view')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
} }
@ -48,7 +49,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(String(payload.role || 'viewer'), 'audit:view')) { if (!payload || !hasPermission(getRole(payload), 'audit:view')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
} }

View File

@ -5,12 +5,13 @@ import { authConfig } from '@/lib/auth-config'
import { dbQueryParams, dbExec } from '@/lib/db' import { dbQueryParams, 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(String(payload.role || 'viewer'), 'roles:manage')) { if (!payload || !hasPermission(getRole(payload), 'roles:manage')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
} }
@ -34,7 +35,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(String(payload.role || 'viewer'), 'roles:manage')) { if (!payload || !hasPermission(getRole(payload), 'roles:manage')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
} }

View File

@ -6,12 +6,13 @@ import { authConfig } from '@/lib/auth-config'
import { dbQueryParams, dbExec } from '@/lib/db' import { dbQueryParams, 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(String(payload.role || 'viewer'), 'users:manage')) { if (!payload || !hasPermission(getRole(payload), 'users:manage')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
} }

View File

@ -4,12 +4,13 @@ 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 { dbQueryParams } 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(String(payload.role || 'viewer'), 'users:view')) { if (!payload || !hasPermission(getRole(payload), 'users:view')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
} }

View File

@ -3,12 +3,13 @@ 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(String(payload.role || 'viewer'), 'dashboard:view')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) if (!payload || !hasPermission(getRole(payload), '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() })
} }

View File

@ -5,12 +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 || !hasPermission(String(payload.role || 'viewer'), 'alerts:manage')) return null if (!payload || !hasPermission(getRole(payload), 'alerts:manage')) return null
return payload return payload
} }

View File

@ -5,12 +5,13 @@ 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(String(payload.role || 'viewer'), 'alerts:manage')) { if (!payload || !hasPermission(getRole(payload), 'alerts:manage')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
} }
const { id } = await params const { id } = await params

View File

@ -5,12 +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'
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(String(payload.role || 'viewer'), 'alerts:manage')) { if (!payload || !hasPermission(getRole(payload), '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')
@ -21,7 +22,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(String(payload.role || 'viewer'), 'alerts:manage')) { if (!payload || !hasPermission(getRole(payload), '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()

View File

@ -4,12 +4,13 @@ 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(String(payload.role || 'viewer'), 'alerts:view')) { if (!payload || !hasPermission(getRole(payload), '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)

View File

@ -1,107 +1,53 @@
// GET /api/auth/callback — OIDC callback 处理 // GET /api/auth/callback — OIDC callbackV2使用 shared handleOidcCallback 工厂)
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { exchangeCodeForToken, getUserinfo } from '@shared/lib/auth/oidc' import { handleOidcCallback } from '@shared/lib/auth/handle-callback'
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 { dbQueryParams, dbExec } from '@/lib/db'
import { writeAuditLog } from '@/lib/audit' import { writeAuditLog } from '@/lib/audit'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url) return handleOidcCallback(request, {
const code = searchParams.get('code') oidc: {
const state = searchParams.get('state') autheliaUrl: authConfig.autheliaUrl,
const error = searchParams.get('error') clientId: authConfig.oidcClientId,
clientSecret: authConfig.oidcClientSecret,
redirectUri: authConfig.oidcRedirectUri,
},
jwtSecret: authConfig.jwtSecret,
cookieDomain: authConfig.cookieDomain,
// 使用 OIDC_REDIRECT_URI 构造公共 URLLESSONS-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带错误处理
let userinfo
try {
userinfo = await getUserinfo(authConfig.autheliaUrl, tokenResult.accessToken)
} catch (e) {
const msg = e instanceof Error ? e.message : 'userinfo_fetch_failed'
return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(msg)}`, baseUrl))
}
// 用户同步(使用参数化查询)
const user = syncOidcUser({
getUser: (username) => { getUser: (username) => {
const rows = dbQueryParams<{ id: number; role: string }>('SELECT id, role FROM users WHERE username = ?', [username]) const rows = dbQueryParams<{ id: number; role: string }>(
'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('INSERT INTO users (username, display_name, email, role) VALUES (?, ?, ?, ?)', [username, displayName, email, 'viewer']) dbExec(
// 使用同一连接查询 last_insert_rowid通过合并为单条 SQL 'INSERT INTO users (username, display_name, email, role) VALUES (?, ?, ?, ?)',
[username, displayName, email, 'viewer']
)
const row = dbQueryParams<{ id: number }>('SELECT last_insert_rowid() AS id', []) 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('UPDATE users SET display_name = ?, email = ?, updated_at = datetime(\'now\', \'+8 hours\') WHERE username = ?', [displayName, email, username]) dbExec(
"UPDATE users SET display_name = ?, email = ?, updated_at = datetime('now', '+8 hours') WHERE username = ?",
[displayName, email, username]
)
}, },
}, userinfo)
// 签发 JWT onAuditLog: (userId, username, req) => {
const token = signJwt({ writeAuditLog({
secret: authConfig.jwtSecret, userId,
payload: { username: userinfo.preferred_username, displayName: userinfo.name, role: user.role }, username,
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({
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
} }

View File

@ -1,34 +1,16 @@
// GET /api/auth/login/oidc — OIDC SSO 重定向 // GET /api/auth/login/oidc — OIDC SSO 重定向V2使用 shared handleOidcLogin 工厂)
import { NextResponse } from 'next/server' import { handleOidcLogin } from '@shared/lib/auth/handle-login'
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(request: Request) {
const url = new URL(request.url) const url = new URL(request.url)
const switchUser = url.searchParams.get('switch') === '1' const switchUser = url.searchParams.get('switch') === '1'
const { codeVerifier, codeChallenge } = generatePkce() return handleOidcLogin({
const state = generateState() autheliaUrl: authConfig.autheliaUrl,
const nonce = generateState() clientId: authConfig.oidcClientId,
clientSecret: authConfig.oidcClientSecret,
const authorizeUrl = buildAuthorizeUrl( redirectUri: authConfig.oidcRedirectUri,
{ autheliaUrl: authConfig.autheliaUrl, clientId: authConfig.oidcClientId, clientSecret: authConfig.oidcClientSecret, redirectUri: authConfig.oidcRedirectUri }, switchUser,
{ codeChallenge, state, nonce, ...(switchUser && { prompt: 'login' }) }, })
)
const response = NextResponse.redirect(authorizeUrl)
// 切换账号时清除旧 session cookie
if (switchUser) {
response.cookies.set('tlyq_session', '', { maxAge: 0, path: '/' })
response.cookies.set('session', '', { maxAge: 0, path: '/' })
}
// 存储 PKCE 参数到 httpOnly cookie5 分钟过期)
const cookieOpts = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, maxAge: 300, path: '/' }
response.cookies.set('oidc_code_verifier', codeVerifier, cookieOpts)
response.cookies.set('oidc_state', state, cookieOpts)
response.cookies.set('oidc_nonce', nonce, cookieOpts)
return response
} }

View File

@ -1,7 +1,7 @@
// 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 { signJwt } from '@shared/lib/auth/jwt' import { signJwtV2 } from '@shared/lib/auth/jwt-v2'
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, dbQueryParams } from '@/lib/db'
@ -51,7 +51,7 @@ export async function POST(request: NextRequest) {
resetRateLimit(rateLimitKey) resetRateLimit(rateLimitKey)
const displayName = localadminUser.display_name || 'localadmin' const displayName = localadminUser.display_name || 'localadmin'
const role = localadminUser.role || 'admin' const role = localadminUser.role || 'admin'
const token = signJwt({ secret: authConfig.jwtSecret, payload: { username: 'localadmin', role, displayName } }) const token = signJwtV2({ secret: authConfig.jwtSecret, iss: 'monitor.tlyq.ai', payload: { username: 'localadmin', displayName } })
writeAuditLog({ writeAuditLog({
username, action: 'login', entityType: 'auth', username, action: 'login', entityType: 'auth',
@ -95,7 +95,7 @@ export async function POST(request: NextRequest) {
ipAddress: ip, ipAddress: ip,
}) })
const token = signJwt({ secret: authConfig.jwtSecret, payload: { username, role, displayName: result.displayName || username } }) const token = signJwtV2({ secret: authConfig.jwtSecret, iss: 'monitor.tlyq.ai', payload: { username, 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 },
}) })

View File

@ -1,11 +1,21 @@
// POST /api/auth/logout — 退出登录 // GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login
import { NextResponse } from 'next/server' import { NextResponse, type NextRequest } from 'next/server'
import { authConfig } from '@/lib/auth-config'
export async function POST() { const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
// 清除 tlyq_session cookie
const logoutUrl = `${authConfig.autheliaUrl}/api/oidc/end_session` /** 清除 tlyq_session + session cookie → 302 跳转 /login */
const response = NextResponse.redirect(logoutUrl) function logoutResponse(request: NextRequest): NextResponse {
response.cookies.set('tlyq_session', '', { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', domain: authConfig.cookieDomain, path: '/', maxAge: 0 }) const host = request.headers.get('x-forwarded-host') || request.headers.get('host') || 'localhost'
const proto = request.headers.get('x-forwarded-proto') || 'https'
const baseUrl = `${proto}://${host}`
const response = NextResponse.redirect(new URL('/login', baseUrl))
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 })
return response return response
} }
export async function GET(request: NextRequest) { return logoutResponse(request) }
export async function POST(request: NextRequest) { return logoutResponse(request) }

View File

@ -1,7 +1,8 @@
// GET /api/auth/me — 当前用户信息 // GET /api/auth/me — 当前用户信息V2JWT 不含 role从本地 DB 查询)
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { verifyJwt } from '@shared/lib/auth/jwt' import { verifyJwtV2, extractIss } from '@shared/lib/auth/jwt-v2'
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
@ -9,16 +10,24 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
} }
const payload = verifyJwt(token, authConfig.jwtSecret) // Layer 3 defense-in-depth提取 iss → 自引用验签
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: { user: { username, display_name: displayName, role },
username: payload.username,
display_name: payload.displayName || payload.username,
role: payload.role || 'viewer',
},
}) })
} }

View File

@ -1,4 +1,6 @@
// GET /api/health — monitor-ai 自身健康检查 // GET /api/health — monitor-ai 自身健康检查
export async function GET() { export async function GET() {
return Response.json({ status: 'OK', timestamp: new Date().toISOString() }) const d = new Date()
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 })
} }

View File

@ -5,13 +5,14 @@ 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(String(payload.role || 'viewer'), 'services:manage')) { if (!hasPermission(getRole(payload), 'services:manage')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
} }

View File

@ -5,13 +5,14 @@ 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(String(payload.role || 'viewer'), 'services:manage')) { if (!payload || !hasPermission(getRole(payload), 'services:manage')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
} }

View File

@ -5,13 +5,14 @@ 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(String(payload.role || 'viewer'), 'services:manage')) return null if (!hasPermission(getRole(payload), 'services:manage')) return null
return payload return payload
} }

View File

@ -6,6 +6,7 @@ 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() { 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`)
@ -19,7 +20,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(String(payload.role || 'viewer'), 'services:manage')) { if (!hasPermission(getRole(payload), 'services:manage')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
} }

View File

@ -6,5 +6,7 @@ export async function GET() {
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++ })
return NextResponse.json({ services, counts, timestamp: new Date().toISOString() }) const d = new Date()
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 })
} }

22
src/lib/get-role.ts Normal file
View File

@ -0,0 +1,22 @@
// 服务端专用:从 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)
}

View File

@ -85,8 +85,8 @@ export class MonitorWorker {
} }
// 数据清理每日UTC+8 时区) // 数据清理每日UTC+8 时区)
const now = new Date() const d = new Date()
const today = new Date(now.getTime() + 8 * 3600000).toISOString().slice(0, 10) const today = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
if (today !== this.lastCleanupDate) { if (today !== this.lastCleanupDate) {
this.cleanupOldData() this.cleanupOldData()
this.lastCleanupDate = today this.lastCleanupDate = today

View File

@ -1,6 +1,4 @@
// src/lib/permissions.ts — RBAC 权限定义 + 检查函数 // src/lib/permissions.ts — RBAC 权限定义 + 客户端安全函数(不含 DB 依赖)
import { verifyJwt } from '@shared/lib/auth/jwt'
import { authConfig } from '@/lib/auth-config'
export const PERMISSIONS = [ export const PERMISSIONS = [
{ key: 'dashboard:view', name: '查看仪表盘' }, { key: 'dashboard:view', name: '查看仪表盘' },
{ key: 'services:view', name: '查看服务列表' }, { key: 'services:view', name: '查看服务列表' },
@ -15,27 +13,17 @@ 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 // admin 拥有全部权限 if (role === 'admin') return true
const perms = ROLE_DEFAULT_PERMISSIONS[role] const perms = ROLE_DEFAULT_PERMISSIONS[role]
return perms ? perms.includes(permissionKey) : false return perms ? perms.includes(permissionKey) : false
} }
// API 服务端权限检查(从 cookie 中读取 role验证 JWT 签名) // checkPermission 已移至 @/lib/get-role.ts服务端专用API routes 请从 get-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
const payload = verifyJwt(token, authConfig.jwtSecret)
if (!payload) return false
const role = (payload.role as string) || 'viewer'
return hasPermission(role, permissionKey)
}

View File

@ -1,8 +1,14 @@
// src/middleware.ts — 使用共享 middleware 工厂 // src/middleware.ts — V2 单 cookie 模型Edge 验签 + iss 校验
import { createMiddleware } from '@shared/lib/auth/middleware' import { createMiddlewareV2 } from '@shared/lib/auth/middleware-v2'
export const middleware = createMiddleware({ const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
adminPaths: ['/admin', '/services', '/settings', '/alerts'], const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
export const middleware = createMiddlewareV2({
jwtSecret,
cookieDomain,
allowedIssuers: ['*'],
enableApiKey: false,
}) })
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