Next.js 14 App Router中间件:兼顾多语言与Cookie设置
解决方案:Next.js 14 App Router 令牌刷新+多语言逻辑兼容
一、中间件改造:同时处理Cookie设置与多语言重定向/重写
核心问题是直接返回重定向/重写响应会覆盖Cookie设置,解决思路是先构建响应对象,再附加Cookie后返回,确保令牌刷新和多语言逻辑互不干扰。
import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export async function middleware(request: NextRequest) { // 1. 提取现有令牌 const accessToken = request.cookies.get('access_token')?.value const refreshToken = request.cookies.get('refresh_token')?.value // 2. 令牌校验与刷新逻辑 let shouldRefresh = false let newAccessToken = accessToken let newRefreshToken = refreshToken if (accessToken) { // 替换为你的JWT过期校验逻辑 const isTokenExpired = await checkTokenExpiry(accessToken) if (isTokenExpired && refreshToken) { // 调用后端刷新接口 const tokens = await fetchNewTokens(refreshToken) if (tokens) { newAccessToken = tokens.accessToken newRefreshToken = tokens.refreshToken shouldRefresh = true } } } // 3. 保留原有多语言逻辑 const locale = request.nextUrl.locale || 'en' const pathname = request.nextUrl.pathname // 场景1:路径无语言前缀,重定向到带前缀路径 if (!pathname.startsWith(`/${locale}`)) { const newUrl = new URL(`/${locale}${pathname}`, request.url) const response = NextResponse.redirect(newUrl) // 附加刷新后的Cookie if (shouldRefresh) { setAuthCookies(response, newAccessToken, newRefreshToken) } return response } // 场景2:路径有语言前缀,返回正常响应 const response = NextResponse.next() if (shouldRefresh) { setAuthCookies(response, newAccessToken, newRefreshToken) } return response } // 封装Cookie设置逻辑,避免重复代码 function setAuthCookies(response: NextResponse, accessToken: string, refreshToken: string) { const isProd = process.env.NODE_ENV === 'production' response.cookies.set('access_token', accessToken, { httpOnly: true, secure: isProd, sameSite: 'strict', maxAge: 3600 // 1小时,按需调整 }) response.cookies.set('refresh_token', refreshToken, { httpOnly: true, secure: isProd, sameSite: 'strict', maxAge: 604800 // 7天,按需调整 }) } // 辅助:校验JWT是否过期 async function checkTokenExpiry(token: string): Promise<boolean> { try { const payload = JSON.parse(atob(token.split('.')[1])) return payload.exp * 1000 < Date.now() } catch (e) { return true // 解析失败视为过期 } } // 辅助:调用后端刷新令牌接口 async function fetchNewTokens(refreshToken: string): Promise<{ accessToken: string; refreshToken: string } | null> { try { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }) }) return res.ok ? res.json() : null } catch (e) { console.error('令牌刷新失败:', e) return null } } // 中间件生效路径配置 export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'] }
二、服务端Token刷新与请求授权示例
1. Server Component 中带授权的请求
// app/protected/page.tsx import { cookies, redirect } from 'next/headers' async function fetchProtectedData() { const cookieStore = cookies() let accessToken = cookieStore.get('access_token')?.value const refreshToken = cookieStore.get('refresh_token')?.value if (!accessToken) redirect('/login') // 首次请求尝试用现有令牌 let res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/protected/data`, { headers: { Authorization: `Bearer ${accessToken}` }, cache: 'no-store' }) // 401则尝试刷新令牌 if (res.status === 401 && refreshToken) { const refreshRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }) }) if (refreshRes.ok) { const newTokens = await refreshRes.json() // 更新Cookie(Server Component需通过Headers设置) const headers = new Headers() headers.append('Set-Cookie', `access_token=${newTokens.accessToken}; HttpOnly; Secure; SameSite=Strict; Max-Age=3600`) headers.append('Set-Cookie', `refresh_token=${newTokens.refreshToken}; HttpOnly; Secure; SameSite=Strict; Max-Age=604800`) // 用新令牌重新请求 res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/protected/data`, { headers: { Authorization: `Bearer ${newTokens.accessToken}` }, cache: 'no-store', headers }) } else { redirect('/login') } } if (!res.ok) throw new Error('请求受保护资源失败') return res.json() } export default async function ProtectedPage() { const data = await fetchProtectedData() return <div>{JSON.stringify(data)}</div> }
2. API Route 中带授权的请求
// app/api/protected/route.ts import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export async function GET(request: NextRequest) { const accessToken = request.cookies.get('access_token')?.value const refreshToken = request.cookies.get('refresh_token')?.value if (!accessToken) { return NextResponse.json({ error: '未授权' }, { status: 401 }) } // 首次请求 let res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/protected/data`, { headers: { Authorization: `Bearer ${accessToken}` } }) // 401时刷新令牌 if (res.status === 401 && refreshToken) { const refreshRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }) }) if (refreshRes.ok) { const newTokens = await refreshRes.json() const response = NextResponse.json(await res.json()) // 更新Cookie setAuthCookies(response, newTokens.accessToken, newTokens.refreshToken) return response } else { return NextResponse.json({ error: '令牌刷新失败' }, { status: 401 }) } } if (!res.ok) { return NextResponse.json({ error: '请求资源失败' }, { status: res.status }) } return NextResponse.json(await res.json()) } // 复用中间件的Cookie设置函数 function setAuthCookies(response: NextResponse, accessToken: string, refreshToken: string) { const isProd = process.env.NODE_ENV === 'production' response.cookies.set('access_token', accessToken, { httpOnly: true, secure: isProd, sameSite: 'strict', maxAge: 3600 }) response.cookies.set('refresh_token', refreshToken, { httpOnly: true, secure: isProd, sameSite: 'strict', maxAge: 604800 }) }
内容的提问来源于stack exchange,提问作者Андрій Мицько
相关产品推荐
相关产品推荐

