You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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,提问作者Андрій Мицько

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.16 23:52:08