fix: OIDC callback 302 redirect + switch user /logout + maxAge support

- handle-callback.ts: 成功时 302 redirect 首页而非返回 200 JSON
- handle-login.ts: 切换账号改用 Authelia /logout 清除 session
- oidc.ts: buildAuthorizeUrl 支持 maxAge 参数
- 新增 V2 认证模块 (handle-login/callback/logout, jwt-v2, jwt-edge, middleware-v2, types-v2)
This commit is contained in:
aiyimickey 2026-07-07 16:32:33 +08:00
parent 8de2298f99
commit 7ac52a8790
9 changed files with 640 additions and 46 deletions

234
lib/auth/handle-callback.ts Normal file
View File

@ -0,0 +1,234 @@
// shared/lib/auth/handle-callback.ts — OIDC callback 工厂(步骤 1a 新增)
// 统一 OIDC 回调流程token 交换 → userinfo 获取 → 用户同步 → JWT 签发 → cookie 设置 → 审计
import { NextResponse, type NextRequest } from 'next/server'
import { signJwtV2 } from './jwt-v2'
import type { OidcCallbackUserInfo } from './types-v2'
interface OidcConfig {
autheliaUrl: string
clientId: string
clientSecret: string
redirectUri: string
}
export interface HandleOidcCallbackConfig {
oidc: OidcConfig
jwtSecret: string
cookieDomain: string
/** 从本地 DB 查找已存在的用户 */
getUser: (username: string) => OidcCallbackUserInfo | null | Promise<OidcCallbackUserInfo | null>
/** 创建新用户(首次登录),返回含 role 的用户信息 */
createUser?: (username: string, displayName: string, email: string) => OidcCallbackUserInfo | Promise<OidcCallbackUserInfo>
/** 更新用户信息displayName、email 变更时) */
updateUser?: (username: string, displayName: string, email: string) => void | Promise<void>
/** 审计日志回调 */
onAuditLog?: (userId: number, username: string, request: NextRequest) => void | Promise<void>
}
/** 从 URL 解析 baseUrlredirectUri 去除路径部分) */
function baseUrlFromRedirectUri(redirectUri: string): string {
try {
const url = new URL(redirectUri)
return `${url.protocol}//${url.host}`
} catch {
return redirectUri.split('/api/')[0]
}
}
/** 构建错误响应redirect 到 /login?error= 并清除 OIDC 临时 cookietry/finally 确保) */
function loginError(baseUrl: string, request: NextRequest): NextResponse {
const response = NextResponse.redirect(new URL(`/login?error=login_failed`, baseUrl))
for (const name of ['oidc_code_verifier', 'oidc_state', 'oidc_nonce']) {
if (request.cookies.get(name)) response.cookies.delete(name)
}
return response
}
/** OIDC 回调处理。成功返回 200 JSON + Set-Cookie失败返回 redirect 到 /login?error= */
export async function handleOidcCallback(
request: NextRequest,
config: HandleOidcCallbackConfig
): Promise<NextResponse> {
const baseUrl = baseUrlFromRedirectUri(config.oidc.redirectUri)
try {
const url = new URL(request.url)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const error = url.searchParams.get('error')
if (error) {
console.error(`OIDC error: ${error}`)
return loginError(baseUrl, request)
}
if (!code || !state) {
return loginError(baseUrl, request)
}
// 1. 验证 state
const savedState = request.cookies.get('oidc_state')?.value
if (state !== savedState) {
console.error('OIDC state mismatch')
return loginError(baseUrl, request)
}
// 2. 获取 code_verifier
const codeVerifier = request.cookies.get('oidc_code_verifier')?.value
if (!codeVerifier) {
console.error('OIDC code_verifier missing')
return loginError(baseUrl, request)
}
// 3. 交换 token
// client_secret_postsecret 放入 POST bodyAuthelia 不支持 client_secret_basic
const tokenRes = await fetch(`${config.oidc.autheliaUrl}/api/oidc/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.oidc.redirectUri,
client_id: config.oidc.clientId,
client_secret: config.oidc.clientSecret,
code_verifier: codeVerifier,
}),
})
if (!tokenRes.ok) {
console.error(`Token exchange failed: ${tokenRes.status}`)
return loginError(baseUrl, request)
}
const tokenData = await tokenRes.json()
const accessToken = tokenData.access_token as string
const idToken = tokenData.id_token as string | undefined
// 4. 验证 id_token nonce严格三阶段生成→id_token 解码→比对,任一缺失/不匹配→拒绝)
if (idToken) {
const idParts = idToken.split('.')
if (idParts.length !== 3) {
console.error('OIDC id_token malformed')
return loginError(baseUrl, request)
}
try {
const idPayload = JSON.parse(Buffer.from(idParts[1], 'base64url').toString())
const savedNonce = request.cookies.get('oidc_nonce')?.value
if (!savedNonce) {
console.error('OIDC nonce missing in cookie')
return loginError(baseUrl, request)
}
if (!idPayload.nonce) {
console.error('OIDC nonce missing in id_token')
return loginError(baseUrl, request)
}
if (idPayload.nonce !== savedNonce) {
console.error('OIDC nonce mismatch')
return loginError(baseUrl, request)
}
// 验证 aud设计方案 §3.3,裸 fetch 替代 openid-client 需手动校验)
if (!idPayload.aud) {
console.error('OIDC aud missing in id_token')
return loginError(baseUrl, request)
}
const audList = Array.isArray(idPayload.aud) ? idPayload.aud : [idPayload.aud]
if (!audList.includes(config.oidc.clientId)) {
console.error('OIDC aud mismatch')
return loginError(baseUrl, request)
}
} catch {
console.error('OIDC id_token parse failed')
return loginError(baseUrl, request)
}
}
// 5. 获取 userinfo
const userinfoRes = await fetch(`${config.oidc.autheliaUrl}/api/oidc/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!userinfoRes.ok) {
console.error(`Userinfo fetch failed: ${userinfoRes.status}`)
return loginError(baseUrl, request)
}
const userinfo = await userinfoRes.json()
const username = userinfo.preferred_username as string
const displayName = (userinfo.name as string) || username
const email = (userinfo.email as string) || ''
// 6. sub 一致性检查id_token sub 必须与 userinfo sub 一致)
if (idToken && userinfo.sub) {
try {
const idParts = idToken.split('.')
const idPayload = JSON.parse(Buffer.from(idParts[1], 'base64url').toString())
if (idPayload.sub && idPayload.sub !== userinfo.sub) {
console.error('OIDC sub mismatch')
return loginError(baseUrl, request)
}
} catch { /* sub check failure is non-fatal if id_token is unavailable */ }
}
// 7. 用户同步(查找或创建)
let user: OidcCallbackUserInfo | null = await Promise.resolve(config.getUser(username))
const isNew = !user
if (!user && config.createUser) {
user = await Promise.resolve(config.createUser(username, displayName, email))
}
if (!user) {
console.error(`User ${username} not found and createUser not provided`)
return loginError(baseUrl, request)
}
// 8. 更新用户信息(如果 displayName 或 email 变更)
if (!isNew && config.updateUser) {
try {
await Promise.resolve(config.updateUser(username, displayName, email))
} catch (e) {
console.error('updateUser failed:', e)
// 不阻塞登录
}
}
// 9. 签发 JWT不含 role
const token = signJwtV2({
secret: config.jwtSecret,
payload: { username, displayName },
iss: config.oidc.autheliaUrl,
})
// 10. 签发 cookie + 302 跳转首页OIDC 回调是浏览器导航,必须 redirect 而非 JSON
const response = NextResponse.redirect(new URL('/', baseUrl))
response.cookies.set('tlyq_session', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
domain: config.cookieDomain,
path: '/',
maxAge: 604800,
})
// 11. 清理 OIDC 临时 cookie
for (const name of ['oidc_code_verifier', 'oidc_state', 'oidc_nonce']) {
response.cookies.delete(name)
}
// 12. 审计日志
if (config.onAuditLog) {
try {
await Promise.resolve(config.onAuditLog(user.id, username, request))
} catch (e) {
console.error('onAuditLog failed:', e)
// 不阻塞登录
}
}
return response
} catch (e) {
console.error('handleOidcCallback error:', e)
return loginError(baseUrl, request)
}
}

64
lib/auth/handle-login.ts Normal file
View File

@ -0,0 +1,64 @@
// shared/lib/auth/handle-login.ts — OIDC 登录工厂(步骤 1a 新增)
// 生成 PKCE + state + nonce → 构建 Authelia 授权 URL → 设置临时 cookie → 302 跳转
import { NextResponse } from 'next/server'
import { generatePkce, generateState, buildAuthorizeUrl, type OidcClientConfig } from './oidc'
interface HandleOidcLoginConfig {
autheliaUrl: string
clientId: string
clientSecret: string
redirectUri: string
/** 强制重新认证("使用其他账号登录",对应 OIDC prompt=login */
switchUser?: boolean
}
const cookieOpts = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: 300, // 5 分钟
} as const
/** OIDC 登录入口。生成 PKCE + nonce → 设置临时 cookie → 重定向到 Authelia */
export async function handleOidcLogin(config: HandleOidcLoginConfig): Promise<NextResponse> {
// 切换账号:通过 Authelia /logout 页面清除 authelia_session
// Authelia 4.38 不支持 /api/oidc/end_session404且 prompt=login&max_age=0
// 均被忽略(仍然跳转同意页面)。唯一可靠方式:先到 Authelia /logout 清除 session
// 用户确认后再回跳本页重新发起 OIDC 授权(此时无 session → Authelia 显示登录页)
if (config.switchUser) {
// 注意rd 参数必须用不带 ?switch=1 的 login/oidc URL否则无限循环
const returnUrl = `${config.redirectUri.replace('/api/auth/callback', '')}/api/auth/login/oidc`
const logoutUrl = `${config.autheliaUrl}/logout?rd=${encodeURIComponent(returnUrl)}`
const response = NextResponse.redirect(logoutUrl)
response.cookies.set('tlyq_session', '', { maxAge: 0, path: '/' })
response.cookies.set('session', '', { maxAge: 0, path: '/' })
return response
}
const { codeVerifier, codeChallenge } = generatePkce()
const state = generateState()
const nonce = generateState()
const oidcConfig: OidcClientConfig = {
autheliaUrl: config.autheliaUrl,
clientId: config.clientId,
clientSecret: config.clientSecret,
redirectUri: config.redirectUri,
}
const authorizeUrl = buildAuthorizeUrl(oidcConfig, {
codeChallenge,
state,
nonce,
})
const response = NextResponse.redirect(authorizeUrl)
// OIDC 临时 cookie5 分钟生命周期)
response.cookies.set('oidc_code_verifier', codeVerifier, cookieOpts)
response.cookies.set('oidc_state', state, cookieOpts)
response.cookies.set('oidc_nonce', nonce, cookieOpts)
return response
}

35
lib/auth/handle-logout.ts Normal file
View File

@ -0,0 +1,35 @@
// shared/lib/auth/handle-logout.ts — 登出工厂(步骤 1a 新增)
// 清除 tlyq_session cookie → 返回 200 JSON
import { NextResponse, type NextRequest } from 'next/server'
interface HandleOidcLogoutConfig {
cookieDomain: string
}
/** 登出:清除 tlyq_session 和 OIDC 临时 cookie → 返回 200 JSON */
export function handleOidcLogout(_req: NextRequest, config: HandleOidcLogoutConfig): NextResponse {
const response = NextResponse.json({ success: true })
// 清除 tlyq_session
response.cookies.set('tlyq_session', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
domain: config.cookieDomain,
path: '/',
maxAge: 0,
})
// 清除 OIDC 临时 cookie
for (const name of ['oidc_code_verifier', 'oidc_state', 'oidc_nonce']) {
response.cookies.set(name, '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: 0,
})
}
return response
}

62
lib/auth/jwt-edge.ts Normal file
View File

@ -0,0 +1,62 @@
// shared/lib/auth/jwt-edge.ts — V2 Edge Runtime JWT 验证(步骤 1a 新增)
// 使用 Web Crypto APIEdge Runtime 兼容3 参数强制校验 iss 匹配
import type { SessionPayloadV2 } from './types-v2'
/** Base64url 解码为 Uint8Array */
function base64urlToBytes(str: string): Uint8Array {
const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const binary = atob(base64 + padding)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
/** 将 ArrayBuffer 转为 hex 字符串 */
function bufferToHex(buffer: ArrayBuffer): string {
return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
/** 验证 JWTEdge Runtime3 参数。expectedIss = '*' 时跳过 iss 校验(迁移模式) */
export async function verifyJwtEdge(
token: string,
secret: string,
expectedIss: string
): Promise<SessionPayloadV2 | null> {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
// 解码 payload
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) as SessionPayloadV2
// 过期检查
if (payload.exp && payload.exp * 1000 < Date.now()) return null
// iss 校验(* 为迁移模式,跳过校验,兼容旧 token 不含 iss
if (expectedIss !== '*' && payload.iss !== expectedIss) return null
// 验签HMAC-SHA256
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
)
const data = encoder.encode(`${parts[0]}.${parts[1]}`)
const signature = base64urlToBytes(parts[2])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const valid = await crypto.subtle.verify('HMAC', key, signature as any, data)
return valid ? payload : null
} catch {
return null
}
}

60
lib/auth/jwt-v2.ts Normal file
View File

@ -0,0 +1,60 @@
// shared/lib/auth/jwt-v2.ts — V2 JWT 签发/验证(步骤 1a 新增,旧 jwt.ts 不动)
// 与 V1 的关键差异signJwtV2 强制注入 issverifyJwtV2 强制校验 iss 匹配
import crypto from 'crypto'
import type { SessionPayloadV2 } from './types-v2'
interface SignJwtV2Options {
secret: string
payload: { username: string; displayName?: string }
/** 签发者标识OA LDAP 用 "oa.tlyq.ai"OIDC 用 autheliaUrl */
iss: string
expiresInSeconds?: number
}
/** 签发 JWTV2强制注入 iss */
export function signJwtV2({ secret, payload, iss, expiresInSeconds = 604800 }: SignJwtV2Options): string {
const header = { alg: 'HS256', typ: 'JWT' }
const now = Math.floor(Date.now() / 1000)
const body = { ...payload, iss, iat: now, exp: now + expiresInSeconds }
const encoded = (obj: object) => Buffer.from(JSON.stringify(obj)).toString('base64url')
const signature = crypto
.createHmac('sha256', secret)
.update(`${encoded(header)}.${encoded(body)}`)
.digest('base64url')
return `${encoded(header)}.${encoded(body)}.${signature}`
}
/** 验证 JWTV23 参数强制校验 iss 匹配 */
export function verifyJwtV2(token: string, secret: string, expectedIss: string): SessionPayloadV2 | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()) as SessionPayloadV2
if (payload.exp && payload.exp * 1000 < Date.now()) return null
if (payload.iss !== expectedIss) return null
const signature = crypto
.createHmac('sha256', secret)
.update(`${parts[0]}.${parts[1]}`)
.digest('base64url')
return signature === parts[2] ? payload : null
} catch {
return null
}
}
/** 从 token 解码提取 iss不验签用于 Layer 2/3 自引用校验:先提取 iss → 再 verifyJwtV2 */
export function extractIss(token: string): string | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()) as SessionPayloadV2
return payload.iss || null
} catch {
return null
}
}

125
lib/auth/middleware-v2.ts Normal file
View File

@ -0,0 +1,125 @@
// shared/lib/auth/middleware-v2.ts — V2 middleware 工厂(步骤 1a 新增,旧 middleware.ts 不动)
// 与 V1 的关键差异:单 cookie 模型、Edge 验签+iss 校验、无 admin 路径检查(鉴权下沉到 layout/API
import { NextResponse, type NextRequest } from 'next/server'
import { verifyJwtEdge } from './jwt-edge'
import type { MiddlewareV2Config } from './types-v2'
// Edge Runtime 兼容的 JWT payload 解码(不验签)
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
while (payload.length % 4) payload += '='
return JSON.parse(atob(payload))
} catch { return null }
}
function isValidPayload(payload: Record<string, unknown> | null): boolean {
if (!payload) return false
return !(payload.exp && (payload.exp as number) < Math.floor(Date.now() / 1000))
}
function noCache(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate')
return response
}
/** 创建 V2 middleware单 cookie 模型Edge 验签) */
export function createMiddlewareV2(cfg: MiddlewareV2Config) {
const publicPaths = cfg.publicPaths || ['/login', '/api/auth', '/api/health', '/_next', '/favicon.ico']
const allowedIssuers = cfg.allowedIssuers
const enableApiKey = cfg.enableApiKey || false
return async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 1. 公开路径放行
if (publicPaths.some(p => pathname.startsWith(p))) {
if (pathname === '/login') {
const token = request.cookies.get('tlyq_session')?.value
const payload = token ? decodeJwtPayload(token) : null
if (isValidPayload(payload)) {
return NextResponse.redirect(new URL('/', request.nextUrl.origin))
}
}
return NextResponse.next()
}
// 2. API Key 认证(可选,迁移期间保留)
if (enableApiKey) {
const authHeader = request.headers.get('authorization')
if (authHeader?.startsWith('Bearer ak_')) {
const key = authHeader.slice(7)
const allowedKeys = process.env.ALLOWED_API_KEYS || ''
if (allowedKeys && allowedKeys.split(',').map(k => k.trim()).includes(key)) {
return NextResponse.next()
}
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: '无效的 API Key' }, { status: 401 })
}
}
}
// 3. 验证 tlyq_session
const token = request.cookies.get('tlyq_session')?.value
if (!token) {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
return noCache(NextResponse.redirect(new URL('/login', request.nextUrl.origin)))
}
// 4. 提取 iss → 验签Edge Runtime
const payload = decodeJwtPayload(token)
if (!payload || !isValidPayload(payload)) {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: '登录已过期' }, { status: 401 })
}
const loginUrl = new URL('/login', request.nextUrl.origin)
const response = NextResponse.redirect(loginUrl)
response.cookies.delete('tlyq_session')
return response
}
const iss = payload.iss as string | undefined
// allowedIssuers 白名单校验("*" 跳过,全量迁移后改为具体值)
if (allowedIssuers.length === 1 && allowedIssuers[0] === '*') {
// 迁移模式:跳过白名单校验,仅验签名+过期
} else if (!iss || !allowedIssuers.includes(iss)) {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: '无效的签发来源' }, { status: 401 })
}
return noCache(NextResponse.redirect(new URL('/login', request.nextUrl.origin)))
}
// 5. 验签(使用提取出的 iss 进行自引用校验)
// 迁移模式allowedIssuers = ["*"]):跳过 iss 校验,仅验签名+过期
// 全量迁移后allowedIssuers 为具体白名单):强制 iss 校验
const jwtSecret = cfg.jwtSecret
const isMigrationMode = allowedIssuers.length === 1 && allowedIssuers[0] === '*'
const expectedIss = isMigrationMode ? (iss || '*') : (iss || '')
const verified = await verifyJwtEdge(token, jwtSecret, expectedIss)
if (!verified) {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: '登录已过期' }, { status: 401 })
}
const loginUrl = new URL('/login', request.nextUrl.origin)
const response = NextResponse.redirect(loginUrl)
response.cookies.delete('tlyq_session')
return response
}
// 6. 放行(设置 session cookie 供客户端展示用httpOnly:false仅含 username
const response = noCache(NextResponse.next())
response.cookies.set('session', JSON.stringify({ username: verified.username }), {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
})
return response
}
}

View File

@ -96,63 +96,46 @@ export function createMiddleware(cfg: MiddlewareConfig = {}) {
} }
} }
// 验证 tlyq_session共享 JWT // 优先验证本地 cookie含 role由本站 OIDC callback 签发)
// 回退到 tlyq_session共享 JWT不含 role仅证明身份
const sharedToken = request.cookies.get('tlyq_session')?.value const sharedToken = request.cookies.get('tlyq_session')?.value
const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null
const localToken = localCookieName ? request.cookies.get(localCookieName)?.value : undefined
const localPayload = localToken ? decodeJwtPayload(localToken) : null
if (isValidPayload(sharedPayload)) { // 1. 本地 cookie 优先(含 role可做 admin 检查)
// 管理路径检查 admin 权限 if (isValidPayload(localPayload)) {
if (adminPaths.some(p => pathname.startsWith(p))) { if (adminPaths.some(p => pathname.startsWith(p))) {
const role = sharedPayload?.role as string const role = localPayload!.role as string
if (role !== adminRole && role !== 'admin') { if (role !== adminRole && role !== 'admin') {
return new NextResponse('Forbidden', { status: 403 }) return new NextResponse('Forbidden', { status: 403 })
} }
} }
if (pathname.startsWith('/api/')) return NextResponse.next()
// 设置 session cookie兼容旧代码 const response = noCache(NextResponse.next())
if (sharedPayload) { response.cookies.set('session', JSON.stringify({ username: localPayload!.username }), {
const response = pathname.startsWith('/api/') ? NextResponse.next() : noCache(NextResponse.next()) httpOnly: true, sameSite: 'lax', path: '/',
response.cookies.set('session', JSON.stringify({ username: sharedPayload.username }), { })
httpOnly: true, sameSite: 'lax', path: '/', return response
})
return response
}
} }
// 回退本地 cookie可选 // 清除无效的本地 cookie
if (localCookieName) { if (localToken) {
const localToken = request.cookies.get(localCookieName)?.value const loginUrl = new URL('/login', request.url)
const localPayload = localToken ? decodeJwtPayload(localToken) : null const response = NextResponse.redirect(loginUrl)
response.cookies.delete(localCookieName!)
if (sharedToken) response.cookies.delete('tlyq_session')
return response
}
if (isValidPayload(localPayload)) { // 2. 回退到 tlyq_session共享 JWT不含 role只验证身份
// 管理路径检查 admin 权限 if (isValidPayload(sharedPayload)) {
if (adminPaths.some(p => pathname.startsWith(p))) { // 注意tlyq_session 不含 roleadmin 检查在各站点 callback 中通过查本地 DB 完成
const role = localPayload?.role as string const response = pathname.startsWith('/api/') ? NextResponse.next() : noCache(NextResponse.next())
if (role !== adminRole && role !== 'admin') { response.cookies.set('session', JSON.stringify({ username: sharedPayload!.username }), {
return new NextResponse('Forbidden', { status: 403 }) httpOnly: true, sameSite: 'lax', path: '/',
} })
} return response
if (pathname.startsWith('/api/')) {
return NextResponse.next()
}
if (localPayload) {
const response = noCache(NextResponse.next())
response.cookies.set('session', JSON.stringify({ username: localPayload.username }), {
httpOnly: true, sameSite: 'lax', path: '/',
})
return response
}
}
// 清除无效的本地 cookie
if (localToken) {
const loginUrl = new URL('/login', request.url)
const response = NextResponse.redirect(loginUrl)
response.cookies.delete(localCookieName)
if (sharedToken) response.cookies.delete('tlyq_session')
return response
}
} }
// 未认证API 返回 401页面重定向登录 // 未认证API 返回 401页面重定向登录

View File

@ -36,6 +36,7 @@ export function buildAuthorizeUrl(config: OidcClientConfig, params: {
state: string state: string
nonce: string nonce: string
prompt?: string prompt?: string
maxAge?: number
}): string { }): string {
const { autheliaUrl, clientId, redirectUri } = config const { autheliaUrl, clientId, redirectUri } = config
const url = new URL(`${autheliaUrl}/api/oidc/authorization`) const url = new URL(`${autheliaUrl}/api/oidc/authorization`)
@ -48,6 +49,7 @@ export function buildAuthorizeUrl(config: OidcClientConfig, params: {
url.searchParams.set('code_challenge', params.codeChallenge) url.searchParams.set('code_challenge', params.codeChallenge)
url.searchParams.set('code_challenge_method', 'S256') url.searchParams.set('code_challenge_method', 'S256')
if (params.prompt) url.searchParams.set('prompt', params.prompt) if (params.prompt) url.searchParams.set('prompt', params.prompt)
if (params.maxAge !== undefined) url.searchParams.set('max_age', String(params.maxAge))
return url.toString() return url.toString()
} }

29
lib/auth/types-v2.ts Normal file
View File

@ -0,0 +1,29 @@
// shared/lib/auth/types-v2.ts — V2 类型定义(步骤 1a 新增,旧 types.ts 不动)
// 与 V1 的关键差异SessionPayloadV2 含 iss不含 role认证与鉴权分离
export interface SessionPayloadV2 {
username: string
displayName?: string
/** JWT 签发者标识OA LDAP 用 "oa.tlyq.ai"OIDC 用 autheliaUrl */
iss: string
iat: number
exp: number
}
/** handleOidcCallback 中 getUser/createUser 返回的用户信息role 不写入 JWT */
export interface OidcCallbackUserInfo {
id: number
role: string
}
/** createMiddlewareV2 的配置 */
export interface MiddlewareV2Config {
jwtSecret: string
cookieDomain: string
/** 允许的 iss 值白名单。迁移期间可设为 ["*"](跳过白名单校验,仅验签名+过期),全量迁移后改为具体值 */
allowedIssuers: string[]
publicPaths?: string[]
enableApiKey?: boolean
/** 管理员角色名,默认 'admin' */
adminRole?: string
}