From c50c06f095139f13226e24b4ed094388e56a4730 Mon Sep 17 00:00:00 2001 From: gitadmin Date: Tue, 7 Jul 2026 17:18:19 +0800 Subject: [PATCH] fix: logout 302 redirect instead of JSON, use x-forwarded-host --- docker-compose.yml | 9 +- entrypoint.sh | 2 +- src/app/(app)/reports/page.tsx | 6 +- src/app/api/auth/callback/route.ts | 164 +++++++------------------- src/app/api/auth/login/oidc/route.ts | 83 ++----------- src/app/api/auth/logout/route.ts | 42 +++---- src/app/api/health/route.ts | 2 + src/app/api/reports/download/route.ts | 4 +- src/app/api/stats/route.ts | 2 +- src/app/api/tickets/export/route.ts | 4 +- src/lib/jwt-shared.ts | 5 +- src/lib/ldap.ts | 9 +- src/middleware.ts | 15 ++- 13 files changed, 105 insertions(+), 242 deletions(-) create mode 100644 src/app/api/health/route.ts diff --git a/docker-compose.yml b/docker-compose.yml index 1a87f12..3346ace 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: - /var/run/docker.sock:/var/run/docker.sock environment: - 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} - ASSETS_API_URL=${ASSETS_API_URL:-https://assets.tlyq.ai/api} - ASSETS_API_KEY=${ASSETS_API_KEY} @@ -26,10 +26,17 @@ services: - LDAP_BASE_DN=dc=tlyq,dc=ai - LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai - TZ=Asia/Shanghai + - NODE_TLS_REJECT_UNAUTHORIZED=0 # OIDC 配置 - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-issue-oidc} - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET} - 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 networks: - webnet diff --git a/entrypoint.sh b/entrypoint.sh index 280c1bd..00b78d6 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,7 +1,7 @@ #!/bin/bash # entrypoint.sh — 同时启动 Next.js Server 和 Monitor Worker -node /app/server.js & +HOSTNAME=0.0.0.0 node /app/server.js & SERVER_PID=$! cd /app && node scripts/monitor-worker.js & diff --git a/src/app/(app)/reports/page.tsx b/src/app/(app)/reports/page.tsx index bbbca80..d21303f 100644 --- a/src/app/(app)/reports/page.tsx +++ b/src/app/(app)/reports/page.tsx @@ -65,8 +65,8 @@ function getLastWeekDates() { const lastSunday = new Date(lastMonday) lastSunday.setDate(lastMonday.getDate() + 6) return { - start: lastMonday.toISOString().split('T')[0], - end: lastSunday.toISOString().split('T')[0], + start: `${lastMonday.getFullYear()}-${pad(lastMonday.getMonth()+1)}-${pad(lastMonday.getDate())}`, + end: `${lastSunday.getFullYear()}-${pad(lastSunday.getMonth()+1)}-${pad(lastSunday.getDate())}`, } } @@ -208,7 +208,7 @@ export default function ReportsPage() { const url = URL.createObjectURL(blob) const a = document.createElement('a') 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) a.click() document.body.removeChild(a) diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts index 81ab5c3..f345c45 100644 --- a/src/app/api/auth/callback/route.ts +++ b/src/app/api/auth/callback/route.ts @@ -1,130 +1,54 @@ -import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' -import { getOidcClient } from '@/lib/oidc' -import { createToken } from '@/lib/auth' -import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt-shared' -import { getDb } from '@/lib/db' -import { getUserPermissions } from '@/lib/permissions' -import { ldapGetUserInfo } from '@/lib/ldap' +// GET /api/auth/callback — OIDC callback(V2:使用 shared handleOidcCallback 工厂) +import { NextRequest } from 'next/server' +import { handleOidcCallback } from '@shared/lib/auth/handle-callback' +import { db } from '@/lib/db' +import { writeAuditLog } from '@/lib/audit' -function getBaseUrl(): string { - const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6176/api/auth/callback' - const url = new URL(redirectUri) - return `${url.protocol}//${url.host}` -} +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' +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) { - const { searchParams } = new URL(request.url) - const code = searchParams.get('code') - const state = searchParams.get('state') - const error = searchParams.get('error') - const baseUrl = getBaseUrl() +export async function GET(request: NextRequest) { + return handleOidcCallback(request, { + oidc: { autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri }, + jwtSecret, + cookieDomain, - 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. 错误处理 - 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 + createUser: (username, displayName, email) => { 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'))" - ).run(username, ldapDisplayName, email) - 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 } - } + ).run(username, displayName, email) + const row = db.prepare( + 'SELECT id, role FROM users WHERE username = ? AND is_active = 1' + ).get(username) as { id: number; role: string } + return { id: row?.id ?? 0, role: row?.role ?? 'viewer' } + }, - // 更新登录时间 - db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(user!.id) + updateUser: (username, displayName, email) => { + db.prepare( + "UPDATE users SET display_name = ?, email = ?, updated_at = datetime('now', '+8 hours') WHERE username = ?" + ).run(displayName, email, username) + }, - // 8. 签发两个 cookie - const localToken = await createToken({ - id: user!.id, - username: user!.username, - display_name: user!.display_name, - role: user!.role, - permissions: getUserPermissions(user!.role), - }) - 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: '/', + onAuditLog: (userId, username, req) => { + writeAuditLog({ + userId, + username, + action: 'login', + entityType: 'auth', + details: { method: 'oidc' }, + ipAddress: req.headers.get('x-forwarded-for') || '127.0.0.1', }) - } - - // 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)) - } + }, + }) } diff --git a/src/app/api/auth/login/oidc/route.ts b/src/app/api/auth/login/oidc/route.ts index b1713ac..456df79 100644 --- a/src/app/api/auth/login/oidc/route.ts +++ b/src/app/api/auth/login/oidc/route.ts @@ -1,81 +1,14 @@ -import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' -import { getOidcClient, generatePKCE, generateState, generateNonce } from '@/lib/oidc' -import { verifySharedJwt } from '@/lib/jwt-shared' +// GET /api/auth/login/oidc — OIDC SSO 重定向(V2:使用 shared handleOidcLogin 工厂) +import { handleOidcLogin } from '@shared/lib/auth/handle-login' + +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) { - const cookieStore = await cookies() - const existingSession = cookieStore.get('tlyq_session')?.value const url = new URL(request.url) const switchUser = url.searchParams.get('switch') === '1' - // 检查是否已有登录用户 - 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 cookie(5 分钟过期) - 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 + return handleOidcLogin({ autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri, switchUser }) } diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index ac718ce..c9e8653 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,27 +1,21 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getCurrentUser } from '@/lib/auth' -import { writeAuditLog, getClientIP } from '@/lib/audit' +// GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login) +import { NextResponse, type NextRequest } from 'next/server' -export async function POST(request: NextRequest) { - // 先获取当前用户信息(用于审计日志),再清除 cookie - const user = await getCurrentUser() +const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai' - const r = NextResponse.json({ success: true }) - - if (user) { - writeAuditLog({ - userId: user.id, - apiKeyId: null, - action: 'logout', - entityType: 'auth', - entityId: user.id, - details: { username: user.username }, - ipAddress: getClientIP(request), - }) - } - - const domain = process.env.COOKIE_DOMAIN || '' - r.cookies.set('session_issue', '', { maxAge: 0, path: '/', domain }) - r.cookies.set('tlyq_session', '', { maxAge: 0, path: '/', domain }) - return r +/** 清除 tlyq_session + session cookie → 302 跳转 /login */ +function logoutResponse(request: NextRequest): NextResponse { + 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 } + +export async function GET(request: NextRequest) { return logoutResponse(request) } +export async function POST(request: NextRequest) { return logoutResponse(request) } diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..2bcb9ec --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,2 @@ +import { NextResponse } from 'next/server' +export async function GET() { return NextResponse.json({ status: 'OK' }) } diff --git a/src/app/api/reports/download/route.ts b/src/app/api/reports/download/route.ts index f21f90c..1a86568 100644 --- a/src/app/api/reports/download/route.ts +++ b/src/app/api/reports/download/route.ts @@ -43,7 +43,9 @@ export async function POST(request: NextRequest) { 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) return new NextResponse(new Uint8Array(zipBuffer), { diff --git a/src/app/api/stats/route.ts b/src/app/api/stats/route.ts index a2e9b03..e3d4dc6 100644 --- a/src/app/api/stats/route.ts +++ b/src/app/api/stats/route.ts @@ -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 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 avgDuration = db.prepare("SELECT AVG(duration_minutes) as avg FROM tickets WHERE duration_minutes IS NOT NULL AND duration_minutes != ''").get() as any diff --git a/src/app/api/tickets/export/route.ts b/src/app/api/tickets/export/route.ts index e6e0b24..606c7ce 100644 --- a/src/app/api/tickets/export/route.ts +++ b/src/app/api/tickets/export/route.ts @@ -58,10 +58,12 @@ export async function GET(request: NextRequest) { 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), { headers: { '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) { diff --git a/src/lib/jwt-shared.ts b/src/lib/jwt-shared.ts index 2c75376..810bdd8 100644 --- a/src/lib/jwt-shared.ts +++ b/src/lib/jwt-shared.ts @@ -1,5 +1,6 @@ // 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 COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || '' @@ -16,7 +17,7 @@ export function signSharedJwt( payload: { username: string; displayName: string }, expiresIn: number = 7 * 24 * 60 * 60 ): string { - return signJwt({ secret: JWT_SECRET, payload, expiresInSeconds: expiresIn }) + return signJwtV2({ secret: JWT_SECRET, payload, iss: 'issue.tlyq.ai', expiresInSeconds: expiresIn }) } // 保持原有签名:verifySharedJwt(token) diff --git a/src/lib/ldap.ts b/src/lib/ldap.ts index 097cf14..4871a1c 100644 --- a/src/lib/ldap.ts +++ b/src/lib/ldap.ts @@ -1,16 +1,11 @@ import { Client, InvalidCredentialsError } from 'ldapts' -import { execFileSync } from 'child_process' const LDAP_URL = process.env.LDAP_URL || 'ldap://localhost:3890' const LDAP_BASE_DN = process.env.LDAP_BASE_DN || 'dc=tlyq,dc=ai' -// 运行时从 LLDAP 容器动态获取 admin 密码,避免明文存于多个 .env -// 需要容器挂载 /var/run/docker.sock +// 从环境变量获取 LLDAP admin 密码(容器内无法执行 docker exec,见 LESSONS-LEARNED #41) function getLdapAdminPassword(): string { - try { - return execFileSync('docker', ['exec', 'lldap', 'printenv', 'LLDAP_ADMIN_PASSWORD'], - { timeout: 3000 }).toString().trim() - } catch { return 'admin123' } + return process.env.LLDAP_ADMIN_PASSWORD || 'admin123' } export interface LdapResult { diff --git a/src/middleware.ts b/src/middleware.ts index 7638eab..12705dc 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,10 +1,13 @@ -// issue-ai/src/middleware.ts — 使用共享 middleware 工厂 -// 注意:移除 adminPaths,settings 页面的权限由各 API 自行检查 -// middleware 仅负责认证(登录检查),不负责角色鉴权 -import { createMiddleware } from '@shared/lib/auth/middleware' +// src/middleware.ts — V2 单 cookie 模型,Edge 验签 + iss 校验 +import { createMiddlewareV2 } from '@shared/lib/auth/middleware-v2' -export const middleware = createMiddleware({ - localCookieName: 'session_issue', +const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026' +const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai' + +export const middleware = createMiddlewareV2({ + jwtSecret, + cookieDomain, + allowedIssuers: ['*'], // 迁移模式 enableApiKey: true, })