diff --git a/lib/auth/handle-callback.ts b/lib/auth/handle-callback.ts new file mode 100644 index 0000000..57dbf89 --- /dev/null +++ b/lib/auth/handle-callback.ts @@ -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 + /** 创建新用户(首次登录),返回含 role 的用户信息 */ + createUser?: (username: string, displayName: string, email: string) => OidcCallbackUserInfo | Promise + /** 更新用户信息(displayName、email 变更时) */ + updateUser?: (username: string, displayName: string, email: string) => void | Promise + /** 审计日志回调 */ + onAuditLog?: (userId: number, username: string, request: NextRequest) => void | Promise +} + +/** 从 URL 解析 baseUrl(redirectUri 去除路径部分) */ +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 临时 cookie(try/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 { + 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_post:secret 放入 POST body(Authelia 不支持 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) + } +} diff --git a/lib/auth/handle-login.ts b/lib/auth/handle-login.ts new file mode 100644 index 0000000..174a3f0 --- /dev/null +++ b/lib/auth/handle-login.ts @@ -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 { + // 切换账号:通过 Authelia /logout 页面清除 authelia_session + // Authelia 4.38 不支持 /api/oidc/end_session(404),且 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 临时 cookie(5 分钟生命周期) + response.cookies.set('oidc_code_verifier', codeVerifier, cookieOpts) + response.cookies.set('oidc_state', state, cookieOpts) + response.cookies.set('oidc_nonce', nonce, cookieOpts) + + return response +} diff --git a/lib/auth/handle-logout.ts b/lib/auth/handle-logout.ts new file mode 100644 index 0000000..19d3706 --- /dev/null +++ b/lib/auth/handle-logout.ts @@ -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 +} diff --git a/lib/auth/jwt-edge.ts b/lib/auth/jwt-edge.ts new file mode 100644 index 0000000..0d814fa --- /dev/null +++ b/lib/auth/jwt-edge.ts @@ -0,0 +1,62 @@ +// shared/lib/auth/jwt-edge.ts — V2 Edge Runtime JWT 验证(步骤 1a 新增) +// 使用 Web Crypto API(Edge 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('') +} + +/** 验证 JWT(Edge Runtime),3 参数。expectedIss = '*' 时跳过 iss 校验(迁移模式) */ +export async function verifyJwtEdge( + token: string, + secret: string, + expectedIss: string +): Promise { + 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 + } +} diff --git a/lib/auth/jwt-v2.ts b/lib/auth/jwt-v2.ts new file mode 100644 index 0000000..fceda88 --- /dev/null +++ b/lib/auth/jwt-v2.ts @@ -0,0 +1,60 @@ +// shared/lib/auth/jwt-v2.ts — V2 JWT 签发/验证(步骤 1a 新增,旧 jwt.ts 不动) +// 与 V1 的关键差异:signJwtV2 强制注入 iss,verifyJwtV2 强制校验 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 +} + +/** 签发 JWT(V2),强制注入 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}` +} + +/** 验证 JWT(V2),3 参数强制校验 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 + } +} diff --git a/lib/auth/middleware-v2.ts b/lib/auth/middleware-v2.ts new file mode 100644 index 0000000..5be2820 --- /dev/null +++ b/lib/auth/middleware-v2.ts @@ -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 | 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 | 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 + } +} diff --git a/lib/auth/middleware.ts b/lib/auth/middleware.ts index f7cabe1..a546e84 100644 --- a/lib/auth/middleware.ts +++ b/lib/auth/middleware.ts @@ -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 sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null + const localToken = localCookieName ? request.cookies.get(localCookieName)?.value : undefined + const localPayload = localToken ? decodeJwtPayload(localToken) : null - if (isValidPayload(sharedPayload)) { - // 管理路径检查 admin 权限 + // 1. 本地 cookie 优先(含 role,可做 admin 检查) + if (isValidPayload(localPayload)) { if (adminPaths.some(p => pathname.startsWith(p))) { - const role = sharedPayload?.role as string + const role = localPayload!.role as string if (role !== adminRole && role !== 'admin') { return new NextResponse('Forbidden', { status: 403 }) } } - - // 设置 session cookie(兼容旧代码) - if (sharedPayload) { - const response = pathname.startsWith('/api/') ? NextResponse.next() : noCache(NextResponse.next()) - response.cookies.set('session', JSON.stringify({ username: sharedPayload.username }), { - httpOnly: true, sameSite: 'lax', path: '/', - }) - return response - } + if (pathname.startsWith('/api/')) return NextResponse.next() + const response = noCache(NextResponse.next()) + response.cookies.set('session', JSON.stringify({ username: localPayload!.username }), { + httpOnly: true, sameSite: 'lax', path: '/', + }) + return response } - // 回退本地 cookie(可选) - if (localCookieName) { - const localToken = request.cookies.get(localCookieName)?.value - const localPayload = localToken ? decodeJwtPayload(localToken) : null + // 清除无效的本地 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 + } - if (isValidPayload(localPayload)) { - // 管理路径检查 admin 权限 - if (adminPaths.some(p => pathname.startsWith(p))) { - const role = localPayload?.role as string - if (role !== adminRole && role !== 'admin') { - return new NextResponse('Forbidden', { status: 403 }) - } - } - - 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 - } + // 2. 回退到 tlyq_session(共享 JWT,不含 role,只验证身份) + if (isValidPayload(sharedPayload)) { + // 注意:tlyq_session 不含 role,admin 检查在各站点 callback 中通过查本地 DB 完成 + const response = pathname.startsWith('/api/') ? NextResponse.next() : noCache(NextResponse.next()) + response.cookies.set('session', JSON.stringify({ username: sharedPayload!.username }), { + httpOnly: true, sameSite: 'lax', path: '/', + }) + return response } // 未认证:API 返回 401,页面重定向登录 diff --git a/lib/auth/oidc.ts b/lib/auth/oidc.ts index a13ee53..ca5aac1 100644 --- a/lib/auth/oidc.ts +++ b/lib/auth/oidc.ts @@ -36,6 +36,7 @@ export function buildAuthorizeUrl(config: OidcClientConfig, params: { state: string nonce: string prompt?: string + maxAge?: number }): string { const { autheliaUrl, clientId, redirectUri } = config 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_method', 'S256') if (params.prompt) url.searchParams.set('prompt', params.prompt) + if (params.maxAge !== undefined) url.searchParams.set('max_age', String(params.maxAge)) return url.toString() } diff --git a/lib/auth/types-v2.ts b/lib/auth/types-v2.ts new file mode 100644 index 0000000..b9375f2 --- /dev/null +++ b/lib/auth/types-v2.ts @@ -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 +}