09-JWT认证在Next.js中的最佳实践

JWT认证在Next.js中的最佳实践

前言

JWT(JSON Web Token)是现代Web应用中最流行的认证方案。本文将介绍如何在Next.js中实现安全、高效的JWT认证系统。

适合读者: 前端开发者、全栈工程师、安全工程师


一、JWT基础

1.1 JWT结构

css 复制代码
JWT = Header.Payload.Signature

Header (头部):
{
  "alg": "HS256",
  "typ": "JWT"
}

Payload (负载):
{
  "user_id": 123,
  "username": "alice",
  "exp": 1735689600,
  "type": "access"
}

Signature (签名):
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret
)

1.2 双Token机制

diff 复制代码
Access Token:
- 短期有效(30分钟)
- 用于API请求
- 存储在内存或localStorage

Refresh Token:
- 长期有效(7天)
- 用于刷新Access Token
- 存储在httpOnly Cookie(更安全)

二、认证流程设计

2.1 完整认证流程

markdown 复制代码
1. 用户登录
   ↓
2. 服务器验证用户名密码
   ↓
3. 生成Access Token + Refresh Token
   ↓
4. 返回Token给客户端
   ↓
5. 客户端存储Token
   ↓
6. 后续请求携带Access Token
   ↓
7. Access Token过期
   ↓
8. 使用Refresh Token刷新
   ↓
9. 获取新的Access Token
   ↓
10. 继续使用

2.2 Token刷新流程

yaml 复制代码
请求API
   ↓
Access Token有效? ─Yes→ 返回数据
   ↓ No
401 Unauthorized
   ↓
自动刷新Token
   ↓
Refresh Token有效? ─Yes→ 获取新Access Token → 重试请求
   ↓ No
跳转登录页

三、认证服务实现

3.1 Token管理

typescript 复制代码
// lib/token-manager.ts
const ACCESS_TOKEN_KEY = 'access_token'
const REFRESH_TOKEN_KEY = 'refresh_token'

export class TokenManager {
  // 存储Token
  static setTokens(accessToken: string, refreshToken: string): void {
    if (typeof window === 'undefined') return
    
    localStorage.setItem(ACCESS_TOKEN_KEY, accessToken)
    localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
  }

  // 获取Access Token
  static getAccessToken(): string | null {
    if (typeof window === 'undefined') return null
    return localStorage.getItem(ACCESS_TOKEN_KEY)
  }

  // 获取Refresh Token
  static getRefreshToken(): string | null {
    if (typeof window === 'undefined') return null
    return localStorage.getItem(REFRESH_TOKEN_KEY)
  }

  // 清除Token
  static clearTokens(): void {
    if (typeof window === 'undefined') return
    
    localStorage.removeItem(ACCESS_TOKEN_KEY)
    localStorage.removeItem(REFRESH_TOKEN_KEY)
  }

  // 检查是否已认证
  static isAuthenticated(): boolean {
    return !!this.getAccessToken()
  }

  // 解析Token
  static parseToken(token: string): any {
    try {
      const base64Url = token.split('.')[1]
      const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
      const jsonPayload = decodeURIComponent(
        atob(base64)
          .split('')
          .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
          .join('')
      )
      return JSON.parse(jsonPayload)
    } catch {
      return null
    }
  }

  // 检查Token是否过期
  static isTokenExpired(token: string): boolean {
    const payload = this.parseToken(token)
    if (!payload || !payload.exp) return true
    
    return Date.now() >= payload.exp * 1000
  }
}

3.2 认证API服务

typescript 复制代码
// services/auth.service.ts
import axios from 'axios'
import { TokenManager } from '@/lib/token-manager'

const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'

export interface LoginCredentials {
  username: string
  password: string
}

export interface RegisterData {
  username: string
  email: string
  password: string
  full_name?: string
}

export interface User {
  id: number
  username: string
  email: string
  full_name?: string
}

export interface LoginResponse {
  access_token: string
  refresh_token: string
  user: User
}

export class AuthService {
  // 登录
  async login(credentials: LoginCredentials): Promise<LoginResponse> {
    const response = await axios.post(`${API_URL}/api/auth/login`, credentials)
    const data = response.data.data

    // 存储Token
    TokenManager.setTokens(data.access_token, data.refresh_token)

    return data
  }

  // 注册
  async register(data: RegisterData): Promise<User> {
    const response = await axios.post(`${API_URL}/api/auth/register`, data)
    return response.data.data
  }

  // 获取当前用户
  async getCurrentUser(): Promise<User> {
    const token = TokenManager.getAccessToken()
    if (!token) {
      throw new Error('未登录')
    }

    const response = await axios.get(`${API_URL}/api/auth/me`, {
      headers: {
        Authorization: `Bearer ${token}`
      }
    })

    return response.data.data
  }

  // 刷新Token
  async refreshToken(): Promise<string> {
    const refreshToken = TokenManager.getRefreshToken()
    if (!refreshToken) {
      throw new Error('无Refresh Token')
    }

    const response = await axios.post(`${API_URL}/api/auth/refresh`, {
      refresh_token: refreshToken
    })

    const newAccessToken = response.data.data.access_token
    const newRefreshToken = response.data.data.refresh_token

    // 更新Token
    TokenManager.setTokens(newAccessToken, newRefreshToken)

    return newAccessToken
  }

  // 登出
  async logout(): Promise<void> {
    try {
      const token = TokenManager.getAccessToken()
      if (token) {
        await axios.post(
          `${API_URL}/api/auth/logout`,
          {},
          {
            headers: {
              Authorization: `Bearer ${token}`
            }
          }
        )
      }
    } finally {
      TokenManager.clearTokens()
    }
  }
}

export const authService = new AuthService()

四、Axios拦截器

4.1 请求拦截器

typescript 复制代码
// lib/axios-instance.ts
import axios, { AxiosInstance, AxiosError } from 'axios'
import { TokenManager } from './token-manager'
import { authService } from '@/services/auth.service'

const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'

// 创建Axios实例
const axiosInstance: AxiosInstance = axios.create({
  baseURL: API_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json'
  }
})

// 请求拦截器
axiosInstance.interceptors.request.use(
  (config) => {
    const token = TokenManager.getAccessToken()
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

// 响应拦截器
let isRefreshing = false
let failedQueue: any[] = []

const processQueue = (error: any, token: string | null = null) => {
  failedQueue.forEach(prom => {
    if (error) {
      prom.reject(error)
    } else {
      prom.resolve(token)
    }
  })

  failedQueue = []
}

axiosInstance.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest: any = error.config

    // 401错误且未重试过
    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // 正在刷新Token,将请求加入队列
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject })
        })
          .then(token => {
            originalRequest.headers.Authorization = `Bearer ${token}`
            return axiosInstance(originalRequest)
          })
          .catch(err => Promise.reject(err))
      }

      originalRequest._retry = true
      isRefreshing = true

      try {
        // 刷新Token
        const newToken = await authService.refreshToken()
        
        // 更新原始请求的Token
        originalRequest.headers.Authorization = `Bearer ${newToken}`
        
        // 处理队列中的请求
        processQueue(null, newToken)
        
        // 重试原始请求
        return axiosInstance(originalRequest)
      } catch (refreshError) {
        // 刷新失败,清除Token并跳转登录
        processQueue(refreshError, null)
        TokenManager.clearTokens()
        
        if (typeof window !== 'undefined') {
          window.location.href = '/login'
        }
        
        return Promise.reject(refreshError)
      } finally {
        isRefreshing = false
      }
    }

    return Promise.reject(error)
  }
)

export default axiosInstance

五、React Hooks

5.1 useAuth Hook

typescript 复制代码
// hooks/useAuth.ts
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { authService } from '@/services/auth.service'
import { TokenManager } from '@/lib/token-manager'
import type { User, LoginCredentials } from '@/services/auth.service'

interface UseAuthReturn {
  user: User | null
  loading: boolean
  error: Error | null
  login: (credentials: LoginCredentials) => Promise<void>
  logout: () => Promise<void>
  isAuthenticated: boolean
}

export function useAuth(): UseAuthReturn {
  const router = useRouter()
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  // 检查认证状态
  useEffect(() => {
    checkAuth()
  }, [])

  const checkAuth = async () => {
    try {
      if (!TokenManager.isAuthenticated()) {
        setLoading(false)
        return
      }

      const userData = await authService.getCurrentUser()
      setUser(userData)
      setError(null)
    } catch (err) {
      setError(err as Error)
      TokenManager.clearTokens()
    } finally {
      setLoading(false)
    }
  }

  const login = useCallback(async (credentials: LoginCredentials) => {
    try {
      setLoading(true)
      setError(null)

      const response = await authService.login(credentials)
      setUser(response.user)

      router.push('/chat')
    } catch (err) {
      setError(err as Error)
      throw err
    } finally {
      setLoading(false)
    }
  }, [router])

  const logout = useCallback(async () => {
    try {
      await authService.logout()
      setUser(null)
      router.push('/login')
    } catch (err) {
      setError(err as Error)
    }
  }, [router])

  return {
    user,
    loading,
    error,
    login,
    logout,
    isAuthenticated: !!user
  }
}

5.2 useRequireAuth Hook

typescript 复制代码
// hooks/useRequireAuth.ts
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from './useAuth'

export function useRequireAuth(redirectTo = '/login') {
  const router = useRouter()
  const { isAuthenticated, loading } = useAuth()

  useEffect(() => {
    if (!loading && !isAuthenticated) {
      router.push(redirectTo)
    }
  }, [isAuthenticated, loading, router, redirectTo])

  return { isAuthenticated, loading }
}

六、路由保护

6.1 客户端路由保护

typescript 复制代码
// app/chat/page.tsx
'use client'

import { useRequireAuth } from '@/hooks/useRequireAuth'

export default function ChatPage() {
  const { loading } = useRequireAuth()

  if (loading) {
    return <div>加载中...</div>
  }

  return (
    <div>
      {/* 聊天界面 */}
    </div>
  )
}

6.2 高阶组件保护

typescript 复制代码
// components/withAuth.tsx
import { ComponentType } from 'react'
import { useRequireAuth } from '@/hooks/useRequireAuth'

export function withAuth<P extends object>(
  Component: ComponentType<P>
): ComponentType<P> {
  return function AuthenticatedComponent(props: P) {
    const { loading } = useRequireAuth()

    if (loading) {
      return <div>加载中...</div>
    }

    return <Component {...props} />
  }
}

// 使用示例
const ProtectedPage = withAuth(ChatPage)

6.3 中间件保护(Next.js 13+)

typescript 复制代码
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const token = request.cookies.get('access_token')?.value

  // 保护的路由
  const protectedPaths = ['/chat', '/profile', '/settings']
  const isProtectedPath = protectedPaths.some(path =>
    request.nextUrl.pathname.startsWith(path)
  )

  if (isProtectedPath && !token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/chat/:path*', '/profile/:path*', '/settings/:path*']
}

七、登录页面实现

7.1 登录表单

typescript 复制代码
// app/login/page.tsx
'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '@/hooks/useAuth'

export default function LoginPage() {
  const router = useRouter()
  const { login } = useAuth()
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(false)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setError('')
    setLoading(true)

    try {
      await login({ username, password })
      // 登录成功,useAuth会自动跳转
    } catch (err: any) {
      setError(err.response?.data?.msg || '登录失败')
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
        <div>
          <h2 className="text-3xl font-bold text-center text-gray-900">
            登录
          </h2>
        </div>

        <form onSubmit={handleSubmit} className="space-y-6">
          {error && (
            <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
              {error}
            </div>
          )}

          <div>
            <label className="block text-sm font-medium text-gray-700">
              用户名
            </label>
            <input
              type="text"
              value={username}
              onChange={(e) => setUsername(e.target.value)}
              required
              className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
            />
          </div>

          <div>
            <label className="block text-sm font-medium text-gray-700">
              密码
            </label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
            />
          </div>

          <button
            type="submit"
            disabled={loading}
            className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
          >
            {loading ? '登录中...' : '登录'}
          </button>
        </form>

        <div className="text-center">
          <a href="/register" className="text-sm text-blue-600 hover:text-blue-500">
            还没有账号?立即注册
          </a>
        </div>
      </div>
    </div>
  )
}

八、安全最佳实践

8.1 XSS防护

typescript 复制代码
// 不要在localStorage中存储敏感信息
// ❌ 不好
localStorage.setItem('user_password', password)

// ✅ 好
// 只存储Token,不存储密码等敏感信息

8.2 CSRF防护

typescript 复制代码
// 使用httpOnly Cookie存储Refresh Token
// 服务端设置
res.cookie('refresh_token', refreshToken, {
  httpOnly: true,  // 防止JavaScript访问
  secure: true,    // 只在HTTPS下传输
  sameSite: 'strict',  // 防止CSRF
  maxAge: 7 * 24 * 60 * 60 * 1000  // 7天
})

8.3 Token过期处理

typescript 复制代码
// 定期检查Token是否即将过期
useEffect(() => {
  const checkTokenExpiry = () => {
    const token = TokenManager.getAccessToken()
    if (!token) return

    if (TokenManager.isTokenExpired(token)) {
      // Token已过期,尝试刷新
      authService.refreshToken().catch(() => {
        // 刷新失败,跳转登录
        router.push('/login')
      })
    }
  }

  // 每分钟检查一次
  const interval = setInterval(checkTokenExpiry, 60000)
  return () => clearInterval(interval)
}, [])

九、总结

JWT认证的核心要点:

双Token机制 - Access + Refresh Token

自动刷新 - 拦截器自动处理过期

路由保护 - 未认证自动跳转

安全存储 - httpOnly Cookie + localStorage

错误处理 - 优雅处理认证失败

下一篇预告: 《FastAPI异步编程:高性能API服务的秘密》


作者简介: 资深开发者,创业者。专注于视频通讯技术领域。国内首本Flutter著作《Flutter技术入门与实战》作者,另著有《Dart语言实战》及《WebRTC音视频开发》等书籍。多年从事视频会议、远程教育等技术研发,对于Android、iOS以及跨平台开发技术有比较深入的研究和应用,作为主要程序员开发了多个应用项目,涉及医疗、交通、银行等领域。

学习资料:

欢迎交流: 如有问题欢迎在评论区讨论 🚀

相关推荐
dozenyaoyida1 分钟前
AI正在悄悄改变我们的生活:从“普通人“到“AI助手“的蜕变之路
人工智能
老刘干货14 分钟前
Prompt工程全解·第四篇:精雕细琢——迭代优化与防御性提示词设计
人工智能·技术人
輕華15 分钟前
OpenCV答题卡识别:从图像预处理到自动评分
人工智能·opencv·计算机视觉
JQLvopkk23 分钟前
机器视觉为何不用普通相机
人工智能·数码相机
AI航向标24 分钟前
OpenClaw 完整本地部署安装(接入飞书)
人工智能·飞书·openclaw
接着奏乐接着舞。26 分钟前
机器学习经验总结整理
人工智能·机器学习
Sim148026 分钟前
iPhone将内置本地大模型,手机端AI实现0 token成本时代来临?
人工智能·ios·智能手机·iphone
AI航向标26 分钟前
Openclaw一键本地部署接入豆包
人工智能·openclaw
就是这么拽呢31 分钟前
论文查重低但AIGC率高,如何补救?
论文阅读·人工智能·ai·aigc
supericeice32 分钟前
创邻科技 AI智算一体机:支持 DeepSeek 671B 与 Qwen3 单机部署,覆盖纯CPU到多GPU多机扩展
大数据·人工智能·科技