fix: logout 302 redirect instead of JSON, use x-forwarded-host

This commit is contained in:
gitadmin 2026-07-07 17:18:19 +08:00
parent 248ccaeb66
commit c50c06f095
13 changed files with 105 additions and 242 deletions

View File

@ -14,7 +14,7 @@ services:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
environment: environment:
- DATABASE_PATH=/app/data/issue.db - DATABASE_PATH=/app/data/issue.db
- JWT_SECRET=oa-shared-jwt-secret-tlyq-2026 - JWT_SECRET=${JWT_SECRET:-oa-shared-jwt-secret-tlyq-2026}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- ASSETS_API_URL=${ASSETS_API_URL:-https://assets.tlyq.ai/api} - ASSETS_API_URL=${ASSETS_API_URL:-https://assets.tlyq.ai/api}
- ASSETS_API_KEY=${ASSETS_API_KEY} - ASSETS_API_KEY=${ASSETS_API_KEY}
@ -26,10 +26,17 @@ services:
- LDAP_BASE_DN=dc=tlyq,dc=ai - LDAP_BASE_DN=dc=tlyq,dc=ai
- LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai - LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- NODE_TLS_REJECT_UNAUTHORIZED=0
# OIDC 配置 # OIDC 配置
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-issue-oidc} - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-issue-oidc}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET} - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-https://issue.tlyq.ai/api/auth/callback} - OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-https://issue.tlyq.ai/api/auth/callback}
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
restart: unless-stopped restart: unless-stopped
networks: networks:
- webnet - webnet

View File

@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# entrypoint.sh — 同时启动 Next.js Server 和 Monitor Worker # entrypoint.sh — 同时启动 Next.js Server 和 Monitor Worker
node /app/server.js & HOSTNAME=0.0.0.0 node /app/server.js &
SERVER_PID=$! SERVER_PID=$!
cd /app && node scripts/monitor-worker.js & cd /app && node scripts/monitor-worker.js &

View File

@ -65,8 +65,8 @@ function getLastWeekDates() {
const lastSunday = new Date(lastMonday) const lastSunday = new Date(lastMonday)
lastSunday.setDate(lastMonday.getDate() + 6) lastSunday.setDate(lastMonday.getDate() + 6)
return { return {
start: lastMonday.toISOString().split('T')[0], start: `${lastMonday.getFullYear()}-${pad(lastMonday.getMonth()+1)}-${pad(lastMonday.getDate())}`,
end: lastSunday.toISOString().split('T')[0], end: `${lastSunday.getFullYear()}-${pad(lastSunday.getMonth()+1)}-${pad(lastSunday.getDate())}`,
} }
} }
@ -208,7 +208,7 @@ export default function ReportsPage() {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `reports_${new Date().toISOString().slice(0, 10)}.zip` const d = new Date(); a.download = `reports_${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}.zip`
document.body.appendChild(a) document.body.appendChild(a)
a.click() a.click()
document.body.removeChild(a) document.body.removeChild(a)

View File

@ -1,130 +1,54 @@
import { NextResponse } from 'next/server' // GET /api/auth/callback — OIDC callbackV2使用 shared handleOidcCallback 工厂)
import { cookies } from 'next/headers' import { NextRequest } from 'next/server'
import { getOidcClient } from '@/lib/oidc' import { handleOidcCallback } from '@shared/lib/auth/handle-callback'
import { createToken } from '@/lib/auth' import { db } from '@/lib/db'
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt-shared' import { writeAuditLog } from '@/lib/audit'
import { getDb } from '@/lib/db'
import { getUserPermissions } from '@/lib/permissions'
import { ldapGetUserInfo } from '@/lib/ldap'
function getBaseUrl(): string { const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6176/api/auth/callback' const oidcClientId = process.env.OIDC_CLIENT_ID || 'issue-oidc'
const url = new URL(redirectUri) const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
return `${url.protocol}//${url.host}` const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://issue.tlyq.ai/api/auth/callback'
} const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
export async function GET(request: Request) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url) return handleOidcCallback(request, {
const code = searchParams.get('code') oidc: { autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri },
const state = searchParams.get('state') jwtSecret,
const error = searchParams.get('error') cookieDomain,
const baseUrl = getBaseUrl()
const cookieStore = await cookies() getUser: (username) => {
const row = db.prepare(
'SELECT id, role FROM users WHERE username = ? AND is_active = 1'
).get(username) as { id: number; role: string } | undefined
return row ?? null
},
// 1. 错误处理 createUser: (username, displayName, email) => {
if (error) {
return NextResponse.redirect(new URL(`/login?error=${error}`, baseUrl))
}
// 2. 验证 state
const savedState = cookieStore.get('oidc_state')?.value
if (!savedState || savedState !== state) {
return NextResponse.redirect(new URL('/login?error=state_mismatch', baseUrl))
}
// 3. 取出 code_verifier
const codeVerifier = cookieStore.get('oidc_code_verifier')?.value
if (!codeVerifier) {
return NextResponse.redirect(new URL('/login?error=missing_verifier', baseUrl))
}
// 4. 验证 nonce
const savedNonce = cookieStore.get('oidc_nonce')?.value
try {
// 5. 换取 token
const client = await getOidcClient()
const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6176/api/auth/callback'
const params = { code, state, iss: searchParams.get('iss') }
const checks = {
code_verifier: codeVerifier,
nonce: savedNonce,
state: savedState,
}
const tokenSet = await client.callback(redirectUri, params, checks)
// 6. 获取 userinfo
const userinfo = await client.userinfo(tokenSet.access_token!)
// 7. 处理用户
const username = (userinfo as any).preferred_username || userinfo.sub!
const displayName = userinfo.name || username
const db = getDb()
// 查找本地用户
let user = db.prepare(
'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1'
).get(username) as { id: number; username: string; display_name: string; role: string } | undefined
if (!user) {
// 自动创建viewer 角色)
const ldapInfo = await ldapGetUserInfo(username)
const ldapDisplayName = ldapInfo?.displayName || displayName
const email = ldapInfo?.email ?? null
db.prepare( db.prepare(
"INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))" "INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
).run(username, ldapDisplayName, email) ).run(username, displayName, email)
user = db.prepare( const row = db.prepare(
'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1' 'SELECT id, role FROM users WHERE username = ? AND is_active = 1'
).get(username) as { id: number; username: string; display_name: string; role: string } ).get(username) as { id: number; role: string }
} return { id: row?.id ?? 0, role: row?.role ?? 'viewer' }
},
// 更新登录时间 updateUser: (username, displayName, email) => {
db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(user!.id) db.prepare(
"UPDATE users SET display_name = ?, email = ?, updated_at = datetime('now', '+8 hours') WHERE username = ?"
).run(displayName, email, username)
},
// 8. 签发两个 cookie onAuditLog: (userId, username, req) => {
const localToken = await createToken({ writeAuditLog({
id: user!.id, userId,
username: user!.username, username,
display_name: user!.display_name, action: 'login',
role: user!.role, entityType: 'auth',
permissions: getUserPermissions(user!.role), details: { method: 'oidc' },
ipAddress: req.headers.get('x-forwarded-for') || '127.0.0.1',
}) })
const sharedToken = signSharedJwt({ username, displayName }) },
const response = NextResponse.redirect(new URL('/', baseUrl))
response.cookies.set('session_issue', localToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60,
path: '/',
})
response.cookies.set(sharedCookieConfig().name, sharedToken, sharedCookieConfig())
// 9. 存储 id_token 用于登出
if (tokenSet.id_token) {
response.cookies.set('oidc_id_token', tokenSet.id_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 86400,
path: '/',
}) })
} }
// 10. 清理 OIDC 临时 cookie
response.cookies.delete('oidc_state')
response.cookies.delete('oidc_nonce')
response.cookies.delete('oidc_code_verifier')
return response
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e)
console.error('OIDC callback error:', errorMsg)
return NextResponse.redirect(new URL(`/login?error=callback_error&detail=${encodeURIComponent(errorMsg)}`, baseUrl))
}
}

View File

@ -1,81 +1,14 @@
import { NextResponse } from 'next/server' // GET /api/auth/login/oidc — OIDC SSO 重定向V2使用 shared handleOidcLogin 工厂)
import { cookies } from 'next/headers' import { handleOidcLogin } from '@shared/lib/auth/handle-login'
import { getOidcClient, generatePKCE, generateState, generateNonce } from '@/lib/oidc'
import { verifySharedJwt } from '@/lib/jwt-shared' const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
const oidcClientId = process.env.OIDC_CLIENT_ID || 'issue-oidc'
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://issue.tlyq.ai/api/auth/callback'
export async function GET(request: Request) { export async function GET(request: Request) {
const cookieStore = await cookies()
const existingSession = cookieStore.get('tlyq_session')?.value
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'
// 检查是否已有登录用户 return handleOidcLogin({ autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri, switchUser })
if (existingSession && !switchUser) {
const existing = verifySharedJwt(existingSession)
if (existing) {
return NextResponse.json({
conflict: true,
currentUser: existing.username,
displayName: existing.displayName,
})
}
}
// 预检 Authelia 健康状态
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
try {
const healthRes = await fetch(`${autheliaUrl}/api/health`, {
signal: AbortSignal.timeout(5000),
})
if (!healthRes.ok) {
return NextResponse.json({ fallback: 'ldap', error: 'Authelia 不可用' }, { status: 503 })
}
} catch {
return NextResponse.json({ fallback: 'ldap', error: 'Authelia 不可达' }, { status: 503 })
}
// 生成 PKCE 参数
const { codeVerifier, codeChallenge } = generatePKCE()
const state = generateState()
const nonce = generateNonce()
// 构建授权 URL
const client = await getOidcClient()
const authorizationUrl = client.authorizationUrl({
scope: 'openid profile email',
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
...(switchUser && { prompt: 'login' }),
})
// 存储到 httpOnly cookie5 分钟过期)
const response = NextResponse.redirect(authorizationUrl)
response.cookies.set('oidc_code_verifier', codeVerifier, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 300,
path: '/',
})
response.cookies.set('oidc_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 300,
path: '/',
})
response.cookies.set('oidc_nonce', nonce, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 300,
path: '/',
})
return response
} }

View File

@ -1,27 +1,21 @@
import { NextRequest, NextResponse } from 'next/server' // GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login
import { getCurrentUser } from '@/lib/auth' import { NextResponse, type NextRequest } from 'next/server'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function POST(request: NextRequest) { const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
// 先获取当前用户信息(用于审计日志),再清除 cookie
const user = await getCurrentUser()
const r = NextResponse.json({ success: true }) /** 清除 tlyq_session + session cookie → 302 跳转 /login */
function logoutResponse(request: NextRequest): NextResponse {
if (user) { const host = request.headers.get('x-forwarded-host') || request.headers.get('host') || 'localhost'
writeAuditLog({ const proto = request.headers.get('x-forwarded-proto') || 'https'
userId: user.id, const baseUrl = `${proto}://${host}`
apiKeyId: null, const response = NextResponse.redirect(new URL('/login', baseUrl))
action: 'logout', response.cookies.set('tlyq_session', '', {
entityType: 'auth', httpOnly: true, secure: process.env.NODE_ENV === 'production',
entityId: user.id, sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
details: { username: user.username },
ipAddress: getClientIP(request),
}) })
response.cookies.set('session', '', { path: '/', maxAge: 0 })
return response
} }
const domain = process.env.COOKIE_DOMAIN || '' export async function GET(request: NextRequest) { return logoutResponse(request) }
r.cookies.set('session_issue', '', { maxAge: 0, path: '/', domain }) export async function POST(request: NextRequest) { return logoutResponse(request) }
r.cookies.set('tlyq_session', '', { maxAge: 0, path: '/', domain })
return r
}

View File

@ -0,0 +1,2 @@
import { NextResponse } from 'next/server'
export async function GET() { return NextResponse.json({ status: 'OK' }) }

View File

@ -43,7 +43,9 @@ export async function POST(request: NextRequest) {
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' }) const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
const downloadName = `reports_${new Date().toISOString().slice(0, 10)}.zip` const d = new Date()
const today = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
const downloadName = `reports_${today}.zip`
const encodedName = encodeURIComponent(downloadName) const encodedName = encodeURIComponent(downloadName)
return new NextResponse(new Uint8Array(zipBuffer), { return new NextResponse(new Uint8Array(zipBuffer), {

View File

@ -17,7 +17,7 @@ export async function GET() {
const resolved = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE current_status = 'resolved'").get() as any).c const resolved = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE current_status = 'resolved'").get() as any).c
const closed = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE current_status = 'closed'").get() as any).c const closed = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE current_status = 'closed'").get() as any).c
const thisMonth = new Date().toISOString().slice(0, 7) const d = new Date(); const thisMonth = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`
const monthTotal = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE assign_time LIKE ?").get(`${thisMonth}%`) as any).c const monthTotal = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE assign_time LIKE ?").get(`${thisMonth}%`) as any).c
const avgDuration = db.prepare("SELECT AVG(duration_minutes) as avg FROM tickets WHERE duration_minutes IS NOT NULL AND duration_minutes != ''").get() as any const avgDuration = db.prepare("SELECT AVG(duration_minutes) as avg FROM tickets WHERE duration_minutes IS NOT NULL AND duration_minutes != ''").get() as any

View File

@ -58,10 +58,12 @@ export async function GET(request: NextRequest) {
ipAddress: getClientIP(request), ipAddress: getClientIP(request),
}) })
const d = new Date()
const exportDate = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
return new NextResponse(new Uint8Array(buffer), { return new NextResponse(new Uint8Array(buffer), {
headers: { headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="tickets_${new Date().toISOString().slice(0, 10)}.xlsx"`, 'Content-Disposition': `attachment; filename="tickets_${exportDate}.xlsx"`,
}, },
}) })
} catch (e) { } catch (e) {

View File

@ -1,5 +1,6 @@
// issue-ai/src/lib/jwt-shared.ts — 引用共享 JWT保持原有接口 // issue-ai/src/lib/jwt-shared.ts — 引用共享 JWT保持原有接口
import { signJwt, verifyJwt } from '@shared/lib/auth/jwt' import { verifyJwt } from '@shared/lib/auth/jwt'
import { signJwtV2 } from '@shared/lib/auth/jwt-v2'
const JWT_SECRET = process.env.JWT_SECRET || 'default-secret-change-me' const JWT_SECRET = process.env.JWT_SECRET || 'default-secret-change-me'
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || '' const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || ''
@ -16,7 +17,7 @@ export function signSharedJwt(
payload: { username: string; displayName: string }, payload: { username: string; displayName: string },
expiresIn: number = 7 * 24 * 60 * 60 expiresIn: number = 7 * 24 * 60 * 60
): string { ): string {
return signJwt({ secret: JWT_SECRET, payload, expiresInSeconds: expiresIn }) return signJwtV2({ secret: JWT_SECRET, payload, iss: 'issue.tlyq.ai', expiresInSeconds: expiresIn })
} }
// 保持原有签名verifySharedJwt(token) // 保持原有签名verifySharedJwt(token)

View File

@ -1,16 +1,11 @@
import { Client, InvalidCredentialsError } from 'ldapts' import { Client, InvalidCredentialsError } from 'ldapts'
import { execFileSync } from 'child_process'
const LDAP_URL = process.env.LDAP_URL || 'ldap://localhost:3890' const LDAP_URL = process.env.LDAP_URL || 'ldap://localhost:3890'
const LDAP_BASE_DN = process.env.LDAP_BASE_DN || 'dc=tlyq,dc=ai' const LDAP_BASE_DN = process.env.LDAP_BASE_DN || 'dc=tlyq,dc=ai'
// 运行时从 LLDAP 容器动态获取 admin 密码,避免明文存于多个 .env // 从环境变量获取 LLDAP admin 密码(容器内无法执行 docker exec见 LESSONS-LEARNED #41
// 需要容器挂载 /var/run/docker.sock
function getLdapAdminPassword(): string { function getLdapAdminPassword(): string {
try { return process.env.LLDAP_ADMIN_PASSWORD || 'admin123'
return execFileSync('docker', ['exec', 'lldap', 'printenv', 'LLDAP_ADMIN_PASSWORD'],
{ timeout: 3000 }).toString().trim()
} catch { return 'admin123' }
} }
export interface LdapResult { export interface LdapResult {

View File

@ -1,10 +1,13 @@
// issue-ai/src/middleware.ts — 使用共享 middleware 工厂 // src/middleware.ts — V2 单 cookie 模型Edge 验签 + iss 校验
// 注意:移除 adminPathssettings 页面的权限由各 API 自行检查 import { createMiddlewareV2 } from '@shared/lib/auth/middleware-v2'
// middleware 仅负责认证(登录检查),不负责角色鉴权
import { createMiddleware } from '@shared/lib/auth/middleware'
export const middleware = createMiddleware({ const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
localCookieName: 'session_issue', const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
export const middleware = createMiddlewareV2({
jwtSecret,
cookieDomain,
allowedIssuers: ['*'], // 迁移模式
enableApiKey: true, enableApiKey: true,
}) })