fix: OIDC callback V2 + logout 302 redirect with x-forwarded-host
This commit is contained in:
parent
14c75a8942
commit
2e1fa92ca3
|
|
@ -7,9 +7,12 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- LDAP_URL=ldap://lldap:3890
|
- LDAP_URL=ldap://lldap:3890
|
||||||
- LDAP_BASE_DN=dc=tlyq,dc=ai
|
- LDAP_BASE_DN=dc=tlyq,dc=ai
|
||||||
- JWT_SECRET=oa-shared-jwt-secret-tlyq-2026
|
- LLDAP_ADMIN_PASSWORD=${LLDAP_ADMIN_PASSWORD}
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-oa-shared-jwt-secret-tlyq-2026}
|
||||||
- COOKIE_DOMAIN=.tlyq.ai
|
- COOKIE_DOMAIN=.tlyq.ai
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
- NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
|
- HOSTNAME=0.0.0.0
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
- ASSETS_DB_PATH=/data/other-sites/assets/assets.db
|
- ASSETS_DB_PATH=/data/other-sites/assets/assets.db
|
||||||
- ISSUE_DB_PATH=/data/other-sites/issue/issue.db
|
- ISSUE_DB_PATH=/data/other-sites/issue/issue.db
|
||||||
|
|
@ -26,6 +29,12 @@ services:
|
||||||
- /var/lib/docker/volumes/issue-ai_issue-data/_data:/data/other-sites/issue
|
- /var/lib/docker/volumes/issue-ai_issue-data/_data:/data/other-sites/issue
|
||||||
networks:
|
networks:
|
||||||
- webnet
|
- webnet
|
||||||
|
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:
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1,27 @@
|
||||||
import { NextResponse } from 'next/server'
|
// GET /api/auth/callback — OIDC callback(V2:OA 签发 tlyq_session)
|
||||||
import { cookies } from 'next/headers'
|
import { NextRequest } from 'next/server'
|
||||||
import { getOidcClient } from '@/lib/oidc'
|
import { handleOidcCallback } from '@shared/lib/auth/handle-callback'
|
||||||
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt'
|
import { ldapUserExists } from '@/lib/ldap'
|
||||||
|
|
||||||
// 从 OIDC_REDIRECT_URI 提取 base URL(避免 request.url 使用 localhost)
|
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
|
||||||
function getBaseUrl(): string {
|
const oidcClientId = process.env.OIDC_CLIENT_ID || 'oa-oidc'
|
||||||
const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6179/api/auth/callback'
|
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
|
||||||
const url = new URL(redirectUri)
|
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://oa.tlyq.ai/api/auth/callback'
|
||||||
return `${url.protocol}//${url.host}`
|
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()
|
// 强制验证 LLDAP 存在性(违反 §2.3 的旧行为已修正)
|
||||||
|
getUser: async (username) => {
|
||||||
|
const exists = await ldapUserExists(username)
|
||||||
|
return exists ? { id: -1, role: 'admin' } : null
|
||||||
|
},
|
||||||
|
|
||||||
// 1. 错误处理
|
// OA 不通过 OIDC 创建/更新用户
|
||||||
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:6179/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. 验证 nonce
|
|
||||||
if (savedNonce && tokenSet.claims) {
|
|
||||||
const claims = tokenSet.claims()
|
|
||||||
if (claims.nonce !== savedNonce) {
|
|
||||||
return NextResponse.redirect(new URL('/login?error=nonce_mismatch', baseUrl))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. 获取 userinfo
|
|
||||||
const userinfo = await client.userinfo(tokenSet.access_token!)
|
|
||||||
|
|
||||||
// 8. 使用 preferred_username 作为用户名(sub 可能是 UUID)
|
|
||||||
const username = (userinfo as any).preferred_username || userinfo.sub!
|
|
||||||
const displayName = userinfo.name || username
|
|
||||||
|
|
||||||
// 9. 签发 tlyq_session cookie
|
|
||||||
const sharedToken = signSharedJwt({ username: username as string, displayName: displayName as string })
|
|
||||||
const cfg = sharedCookieConfig()
|
|
||||||
|
|
||||||
const response = NextResponse.redirect(new URL('/', baseUrl))
|
|
||||||
response.cookies.set(cfg.name, sharedToken, cfg)
|
|
||||||
|
|
||||||
// 10. 存储 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: '/',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11. 清理 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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,13 @@
|
||||||
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'
|
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
|
||||||
|
const oidcClientId = process.env.OIDC_CLIENT_ID || 'oa-oidc'
|
||||||
|
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
|
||||||
|
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://oa.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 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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,23 @@
|
||||||
import { NextResponse } from 'next/server'
|
// GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login)
|
||||||
import { cookies } from 'next/headers'
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
|
||||||
const cookieStore = await cookies()
|
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
||||||
const domain = process.env.COOKIE_DOMAIN || ''
|
|
||||||
|
|
||||||
// 清除所有相关 cookie(必须指定 domain 以清除跨域 cookie)
|
/** 清除 tlyq_session + session cookie → 302 跳转 /login */
|
||||||
cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/', domain })
|
function logoutResponse(request: NextRequest): NextResponse {
|
||||||
cookieStore.set('session', '', { maxAge: 0, path: '/', domain })
|
// 使用请求头获取真实域名(nginx 反向代理后 nextUrl.origin 是容器内部地址)
|
||||||
cookieStore.set('oidc_id_token', '', { maxAge: 0, path: '/', domain })
|
const host = request.headers.get('x-forwarded-host') || request.headers.get('host') || 'localhost'
|
||||||
|
const proto = request.headers.get('x-forwarded-proto') || 'https'
|
||||||
// Authelia 4.38 不支持 end_session_endpoint,直接跳转登录页
|
const baseUrl = `${proto}://${host}`
|
||||||
// Authelia session 会在 cookie 过期后自动清除
|
const response = NextResponse.redirect(new URL('/login', baseUrl))
|
||||||
// 从请求 URL 动态获取 base URL,避免硬编码 localhost
|
response.cookies.set('tlyq_session', '', {
|
||||||
const { origin } = new URL(request.url)
|
httpOnly: true, secure: process.env.NODE_ENV === 'production',
|
||||||
return NextResponse.redirect(new URL('/login', origin))
|
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) }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// oa-ai/src/lib/jwt.ts — 引用共享 JWT,保持原有接口
|
// oa-ai/src/lib/jwt.ts — V2:使用 signJwtV2(含 iss: 'oa.tlyq.ai'),旧函数保留兼容
|
||||||
import { signJwt, verifyJwt } from '@shared/lib/auth/jwt'
|
import { signJwt, 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 || ''
|
||||||
|
|
@ -11,15 +12,15 @@ export interface SharedSession {
|
||||||
exp: number
|
exp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保持原有签名:signSharedJwt(payload, expiresIn)
|
// 保持原有签名:signSharedJwt(payload, expiresIn) → 内部改用 signJwtV2
|
||||||
export function signSharedJwt(
|
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: 'oa.tlyq.ai', expiresInSeconds: expiresIn })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保持原有签名:verifySharedJwt(token)
|
// 保持原有签名:verifySharedJwt(token) — 兼容旧 token(无 iss)和新 token(有 iss)
|
||||||
export function verifySharedJwt(token: string): SharedSession | null {
|
export function verifySharedJwt(token: string): SharedSession | null {
|
||||||
const payload = verifyJwt(token, JWT_SECRET)
|
const payload = verifyJwt(token, JWT_SECRET)
|
||||||
if (!payload) return null
|
if (!payload) return null
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,26 @@
|
||||||
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 密码
|
// 从环境变量获取 LLDAP admin 密码(Docker 容器内无法执行 docker exec)
|
||||||
function getLdapAdminPassword(): string {
|
function getLdapAdminPassword(): string {
|
||||||
|
return process.env.LLDAP_ADMIN_PASSWORD || 'admin123'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户是否存在于 LLDAP 中(用于 OIDC callback 验证)
|
||||||
|
export async function ldapUserExists(username: string): Promise<boolean> {
|
||||||
|
const adminDn = `uid=admin,ou=people,${LDAP_BASE_DN}`
|
||||||
|
const adminPass = getLdapAdminPassword()
|
||||||
|
const client = new Client({ url: LDAP_URL, timeout: 5000 })
|
||||||
try {
|
try {
|
||||||
return execFileSync('docker', ['exec', 'lldap', 'printenv', 'LLDAP_ADMIN_PASSWORD'],
|
await client.bind(adminDn, adminPass)
|
||||||
{ timeout: 3000 }).toString().trim()
|
const { searchEntries } = await client.search(LDAP_BASE_DN, {
|
||||||
} catch { return 'admin123' }
|
scope: 'sub', filter: `(uid=${username})`, timeLimit: 3,
|
||||||
|
})
|
||||||
|
return searchEntries.length > 0
|
||||||
|
} catch { return false }
|
||||||
|
finally { try { await client.unbind() } catch { /* */ } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查用户是否属于 lldap_admin 组(用于管理员权限判断)
|
// 检查用户是否属于 lldap_admin 组(用于管理员权限判断)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
// oa-ai/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'
|
||||||
|
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
||||||
|
|
||||||
|
export const middleware = createMiddlewareV2({
|
||||||
|
jwtSecret,
|
||||||
|
cookieDomain,
|
||||||
|
allowedIssuers: ['*'], // 迁移模式
|
||||||
publicPaths: ['/login', '/api/auth', '/api/health', '/api/admin', '/setup-password', '/_next', '/favicon.ico'],
|
publicPaths: ['/login', '/api/auth', '/api/health', '/api/admin', '/setup-password', '/_next', '/favicon.ico'],
|
||||||
adminPaths: ['/admin'],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'] }
|
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'] }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue