diff --git a/docker-compose.yml b/docker-compose.yml index e2e7f0e..b73322f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,9 +7,12 @@ services: environment: - LDAP_URL=ldap://lldap:3890 - 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 - NODE_ENV=production + - NODE_TLS_REJECT_UNAUTHORIZED=0 + - HOSTNAME=0.0.0.0 - TZ=Asia/Shanghai - ASSETS_DB_PATH=/data/other-sites/assets/assets.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 networks: - 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 networks: diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts index 10ce534..15e6f81 100644 --- a/src/app/api/auth/callback/route.ts +++ b/src/app/api/auth/callback/route.ts @@ -1,99 +1,27 @@ -import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' -import { getOidcClient } from '@/lib/oidc' -import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt' +// GET /api/auth/callback — OIDC callback(V2:OA 签发 tlyq_session) +import { NextRequest } from 'next/server' +import { handleOidcCallback } from '@shared/lib/auth/handle-callback' +import { ldapUserExists } from '@/lib/ldap' -// 从 OIDC_REDIRECT_URI 提取 base URL(避免 request.url 使用 localhost) -function getBaseUrl(): string { - const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6179/api/auth/callback' - const url = new URL(redirectUri) - return `${url.protocol}//${url.host}` -} - -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() - - const cookieStore = await cookies() - - // 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: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)) - } +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' +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: NextRequest) { + return handleOidcCallback(request, { + oidc: { autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri }, + jwtSecret, + cookieDomain, + + // 强制验证 LLDAP 存在性(违反 §2.3 的旧行为已修正) + getUser: async (username) => { + const exists = await ldapUserExists(username) + return exists ? { id: -1, role: 'admin' } : null + }, + + // OA 不通过 OIDC 创建/更新用户 + }) } diff --git a/src/app/api/auth/login/oidc/route.ts b/src/app/api/auth/login/oidc/route.ts index 9171a98..ac98a8b 100644 --- a/src/app/api/auth/login/oidc/route.ts +++ b/src/app/api/auth/login/oidc/route.ts @@ -1,81 +1,13 @@ -import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' -import { getOidcClient, generatePKCE, generateState, generateNonce } from '@/lib/oidc' -import { verifySharedJwt } from '@/lib/jwt' +// 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 || '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) { - 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 17195fe..a100081 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,18 +1,23 @@ -import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' +// GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login) +import { NextResponse, type NextRequest } from 'next/server' -export async function POST(request: Request) { - const cookieStore = await cookies() - const domain = process.env.COOKIE_DOMAIN || '' +const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai' +const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai' - // 清除所有相关 cookie(必须指定 domain 以清除跨域 cookie) - cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/', domain }) - cookieStore.set('session', '', { maxAge: 0, path: '/', domain }) - cookieStore.set('oidc_id_token', '', { maxAge: 0, path: '/', domain }) - - // Authelia 4.38 不支持 end_session_endpoint,直接跳转登录页 - // Authelia session 会在 cookie 过期后自动清除 - // 从请求 URL 动态获取 base URL,避免硬编码 localhost - const { origin } = new URL(request.url) - return NextResponse.redirect(new URL('/login', origin)) +/** 清除 tlyq_session + session cookie → 302 跳转 /login */ +function logoutResponse(request: NextRequest): NextResponse { + // 使用请求头获取真实域名(nginx 反向代理后 nextUrl.origin 是容器内部地址) + 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/lib/jwt.ts b/src/lib/jwt.ts index dccea1f..7944309 100644 --- a/src/lib/jwt.ts +++ b/src/lib/jwt.ts @@ -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 { 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 || '' @@ -11,15 +12,15 @@ export interface SharedSession { exp: number } -// 保持原有签名:signSharedJwt(payload, expiresIn) +// 保持原有签名:signSharedJwt(payload, expiresIn) → 内部改用 signJwtV2 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: 'oa.tlyq.ai', expiresInSeconds: expiresIn }) } -// 保持原有签名:verifySharedJwt(token) +// 保持原有签名:verifySharedJwt(token) — 兼容旧 token(无 iss)和新 token(有 iss) export function verifySharedJwt(token: string): SharedSession | null { const payload = verifyJwt(token, JWT_SECRET) if (!payload) return null diff --git a/src/lib/ldap.ts b/src/lib/ldap.ts index 6c92091..dc547db 100644 --- a/src/lib/ldap.ts +++ b/src/lib/ldap.ts @@ -1,15 +1,26 @@ 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 密码 +// 从环境变量获取 LLDAP admin 密码(Docker 容器内无法执行 docker exec) function getLdapAdminPassword(): string { + return process.env.LLDAP_ADMIN_PASSWORD || 'admin123' +} + +// 验证用户是否存在于 LLDAP 中(用于 OIDC callback 验证) +export async function ldapUserExists(username: string): Promise { + const adminDn = `uid=admin,ou=people,${LDAP_BASE_DN}` + const adminPass = getLdapAdminPassword() + const client = new Client({ url: LDAP_URL, timeout: 5000 }) try { - return execFileSync('docker', ['exec', 'lldap', 'printenv', 'LLDAP_ADMIN_PASSWORD'], - { timeout: 3000 }).toString().trim() - } catch { return 'admin123' } + await client.bind(adminDn, adminPass) + const { searchEntries } = await client.search(LDAP_BASE_DN, { + scope: 'sub', filter: `(uid=${username})`, timeLimit: 3, + }) + return searchEntries.length > 0 + } catch { return false } + finally { try { await client.unbind() } catch { /* */ } } } // 检查用户是否属于 lldap_admin 组(用于管理员权限判断) diff --git a/src/middleware.ts b/src/middleware.ts index 1fa1300..39db3eb 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,9 +1,14 @@ -// oa-ai/src/middleware.ts — 使用共享 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({ +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'], - adminPaths: ['/admin'], }) export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'] }