fix: logout 302 redirect instead of JSON, use x-forwarded-host
This commit is contained in:
parent
5961810a43
commit
39a88a8220
|
|
@ -21,4 +21,4 @@ COPY --from=builder /app/.next/static ./.next/static
|
|||
COPY --from=builder /app/public ./public
|
||||
RUN mkdir -p /app/data /app/uploads
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["sh", "-c", "HOSTNAME=0.0.0.0 node server.js"]
|
||||
|
|
|
|||
|
|
@ -13,11 +13,12 @@ services:
|
|||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/assets.db
|
||||
- JWT_SECRET=oa-shared-jwt-secret-tlyq-2026
|
||||
- JWT_SECRET=${JWT_SECRET:-oa-shared-jwt-secret-tlyq-2026}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||
- NODE_ENV=production
|
||||
- COOKIE_DOMAIN=.tlyq.ai
|
||||
- TZ=Asia/Shanghai
|
||||
- NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
- AUTHELIA_URL=${AUTHELIA_URL:-https://sso.tlyq.ai}
|
||||
- LDAP_URL=ldap://lldap:3890
|
||||
- LDAP_BASE_DN=dc=tlyq,dc=ai
|
||||
|
|
@ -34,6 +35,12 @@ services:
|
|||
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-assets-oidc}
|
||||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
|
||||
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-https://assets.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
|
||||
|
|
|
|||
|
|
@ -1,121 +1,51 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getOidcClient } from '@/lib/oidc'
|
||||
import { signJwt } from '@/lib/auth'
|
||||
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt'
|
||||
// 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 { ldapGetUserInfo } from '@/lib/ldap'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
|
||||
function getBaseUrl(): string {
|
||||
const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6177/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 || 'assets-oidc'
|
||||
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
|
||||
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://assets.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, username, role FROM users WHERE username = ? AND is_active = 1'
|
||||
).get(username) as { id: number; role: string } | undefined
|
||||
return row ? { id: row.id, role: row.role } : 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:6177/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
|
||||
|
||||
// 查找本地用户
|
||||
let user = db.prepare(
|
||||
'SELECT id, username, role FROM users WHERE username = ? AND is_active = 1'
|
||||
).get(username) as { id: number; username: 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, role FROM users WHERE username = ? AND is_active = 1'
|
||||
).get(username) as { id: number; username: 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 = signJwt({ userId: user!.id, username: user!.username, role: user!.role })
|
||||
const sharedToken = signSharedJwt({ username, displayName })
|
||||
|
||||
const response = NextResponse.redirect(new URL('/', baseUrl))
|
||||
response.cookies.set('session_assets', localToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 86400,
|
||||
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))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || 'assets-oidc'
|
||||
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
|
||||
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://assets.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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,21 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getSession } 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: Request) {
|
||||
const session = await getSession()
|
||||
const cookieStore = await cookies()
|
||||
const domain = process.env.COOKIE_DOMAIN || ''
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
||||
|
||||
// 清除 cookie 时必须指定与设置时相同的 domain
|
||||
cookieStore.set('session_assets', '', { maxAge: 0, path: '/', domain })
|
||||
cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/', domain })
|
||||
cookieStore.set('oidc_id_token', '', { maxAge: 0, path: '/', domain })
|
||||
|
||||
if (session) {
|
||||
writeAuditLog({
|
||||
userId: session.userId,
|
||||
apiKeyId: null,
|
||||
action: 'logout',
|
||||
entityType: 'auth',
|
||||
entityId: session.userId,
|
||||
details: { username: session.username },
|
||||
ipAddress: getClientIP(request)
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
/** 清除 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) }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
export async function GET() { return NextResponse.json({ status: 'OK' }) }
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
// assets-ai/src/lib/jwt.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'
|
||||
import type { AuthConfig } from '@shared/lib/auth/types'
|
||||
|
||||
// 从环境变量读取配置
|
||||
|
|
@ -21,7 +22,7 @@ export function signSharedJwt(
|
|||
payload: { username: string; displayName: string },
|
||||
expiresIn: number = 7 * 24 * 60 * 60
|
||||
): string {
|
||||
return signJwt({ secret: config.jwtSecret, payload, expiresInSeconds: expiresIn })
|
||||
return signJwtV2({ secret: config.jwtSecret, payload, iss: 'assets.tlyq.ai', expiresInSeconds: expiresIn })
|
||||
}
|
||||
|
||||
// 保持原有签名:verifySharedJwt(token)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
// assets-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({
|
||||
localCookieName: 'session_assets',
|
||||
adminPaths: ['/settings'],
|
||||
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,
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue