全栈项目开发第七天:构建企业级前端鉴权体系:JWT + Axios 拦截器 + Zustand 持久化实战

0 阅读6分钟

🛡️ 构建企业级前端鉴权体系:JWT + Axios 拦截器 + Zustand 持久化实战

摘要:在前端全栈开发中,登录不仅仅是画一个表单。真正的挑战在于如何构建一套无感知、高安全、可维护的鉴权体系。本文基于 React + TypeScript 技术栈,深度解析如何利用 Mock.js 模拟 JWT 签发流程,通过 Axios 拦截器实现自动化令牌注入,并结合 Zustand 的持久化中间件打造“刷新不掉线”的用户状态管理方案。

🎯 核心目标:从“能登录”到“优雅鉴权”

很多初级项目在实现登录时,往往只是简单的 POST 请求拿到 Token 存一下。但在生产环境中,我们需要解决以下三个核心痛点:

  1. 安全性:Token 如何安全存储?如何在每次请求中自动携带?
  2. 用户体验:刷新页面后登录态是否丢失?Token 过期如何无感处理?
  3. 代码解耦:业务组件是否应该关心 Token 的存在?

今天,我们将通过重构 Notes 项目的认证模块,给出一个标准的企业级解决方案


🔐 一、后端模拟:用 Mock.js 还原真实 JWT 流转

在没有真实后端的情况下,我们不能只返回一个简单的 { success: true }。为了测试前端的鉴权逻辑,Mock 层必须完整模拟 JWT 的签发与验证过程

1.1 引入 jsonwebtoken 进行真实签名

普通的 Mock 数据只是静态 JSON,而 JWT 需要动态签名。我们在 Mock 服务中直接引入 jsonwebtoken 库:

// mock/auth.ts
import jwt from 'jsonwebtoken';

const JWT_SECRET = 'your-super-secret-key-change-in-prod'; // 模拟后端密钥

export default [
  {
    url: '/api/auth/login',
    method: 'post',
    timeout: 1500, // 刻意制造延迟,测试 Loading 态
    response: ({ body }) => {
      const { username, password } = body;

      // 1. 模拟数据库校验
      if (username !== 'admin' || password !== '123456') {
        return { code: 401, msg: 'Invalid credentials' };
      }

      // 2. 生成真实的 JWT Token
      // Payload 中只存非敏感信息 (ID, Role),绝不存密码!
      const token = jwt.sign(
        { userId: 1, role: 'admin', name: 'Admin User' },
        JWT_SECRET,
        { expiresIn: '2h' } // 设置较短有效期以便测试过期逻辑
      );

      return {
        code: 200,
        data: {
          token,
          user: { id: 1, name: 'Admin User', avatar: '...' }
        }
      };
    }
  },
  {
    // 模拟受保护的资源接口,用于验证 Token
    url: '/api/user/profile',
    method: 'get',
    response: ({ headers }) => {
      const authHeader = headers['authorization'];
      if (!authHeader || !authHeader.startsWith('Bearer ')) {
        return { code: 401, msg: 'Missing token' };
      }

      const token = authHeader.split(' ')[1];
      try {
        // 3. 模拟后端验证签名
        const decoded = jwt.verify(token, JWT_SECRET);
        return { code: 200, data: decoded };
      } catch (e) {
        return { code: 401, msg: 'Token expired or invalid' };
      }
    }
  }
];

💡 架构思考

  • 为什么要模拟 verify 过程?因为前端拦截器需要测试 401 错误处理逻辑(如自动登出、跳转登录页)。如果 Mock 只返回成功,这部分关键代码将无法被验证。

⚙️ 二、Axios 拦截器:打造“无感知”鉴权中间件

将 Token 的处理逻辑散落在各个组件中是大忌。我们需要利用 Axios 的拦截器(Interceptors)建立一个统一的安全网关

2.1 请求拦截器:自动注入令牌

核心逻辑:在请求发出前,从全局状态中读取 Token 并注入 Header。

// lib/axios.ts
import axios from 'axios';
import { useUserStore } from '@/store/user';

const apiClient = axios.create({
  baseURL: '/api',
  timeout: 10000,
});

apiClient.interceptors.request.use((config) => {
  // 关键点:在非组件环境中获取 Zustand 状态
  const token = useUserStore.getState().token;
  
  if (token) {
    // 遵循 RFC 6750 标准:Bearer Token
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
}, (error) => Promise.reject(error));

🚫 常见误区

  • 不要在每个 API 函数里手动传 token 参数。
  • 不要直接从 localStorage 读取 Token,应通过 Store 统一管理,保证数据源唯一(Single Source of Truth)。

2.2 响应拦截器:统一错误治理

核心逻辑:拦截 401 状态码,执行全局登出逻辑,避免每个组件都写 if (res.code === 401)

apiClient.interceptors.response.use(
  (response) => {
    // 统一解包,组件直接拿 data
    return response.data; 
  },
  (error) => {
    if (error.response?.status === 401) {
      // 1. 清除本地状态
      useUserStore.getState().logout();
      
      // 2. 强制跳转登录页 (排除登录页本身死循环)
      if (window.location.pathname !== '/login') {
        window.location.href = '/login';
      }
      
      // 3. 可选:Toast 提示用户
      // toast.error('会话已过期,请重新登录');
    }
    return Promise.reject(error);
  }
);

✨ 优势
业务组件(如 PostListUserProfile)完全不需要知道 Token 是否存在或是否过期。它们只管调用 API,鉴权失败后的清理工作由拦截器自动兜底


🧠 三、Zustand 持久化:解决“刷新即登出”难题

传统的 Redux 或 Context 在页面刷新后会丢失状态。我们需要利用 persist 中间件将用户状态同步到 localStorage

3.1 配置持久化策略

// store/user.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface UserState {
  token: string | null;
  user: UserInfo | null;
  isAuthenticated: boolean;
  login: (token: string, user: UserInfo) => void;
  logout: () => void;
}

export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      token: null,
      user: null,
      isAuthenticated: false,

      login: (token, user) => set({ token, user, isAuthenticated: true }),
      
      logout: () => {
        set({ token: null, user: null, isAuthenticated: false });
        // persist 会自动检测到状态变化并清除 localStorage 中的对应项
      },
    }),
    {
      name: 'auth-storage', // localStorage key
      // 优化:只持久化数据字段,不持久化方法
      partialize: (state) => ({
        token: state.token,
        user: state.user,
        isAuthenticated: state.isAuthenticated,
      }),
    }
  )
);

3.2 进阶:静默重连(Silent Re-authentication)

仅仅从 LocalStorage 恢复状态是不够的,因为 Token 可能已在服务端失效。最佳实践是在应用初始化时进行一次静默验证

// App.tsx 或 main.tsx
useEffect(() => {
  const initAuth = async () => {
    const token = useUserStore.getState().token;
    if (!token) return;

    try {
      // 调用一个轻量级的验证接口
      await apiClient.get('/user/profile'); 
      // 如果成功,状态保持;如果失败,拦截器会自动触发 logout
    } catch (e) {
      console.warn('Silent auth failed');
    }
  };
  
  initAuth();
}, []);

🎨 四、登录组件:关注点分离

有了底层的完善支撑,UI 组件变得极其简洁。它只需要关心表单收集状态反馈

// pages/Login.tsx
const handleLogin = async (e: FormEvent) => {
  e.preventDefault();
  setLoading(true);
  
  try {
    // 1. 调用 API (拦截器会自动处理 Token 存储吗?不,这里只拿 Token)
    // 注意:登录接口通常不需要带 Token,但会返回 Token
    const res = await apiClient.post('/auth/login', formData); 
    
    // 2. 更新全局状态 (触发 persist 存入 localStorage)
    useUserStore.getState().login(res.data.token, res.data.user);
    
    // 3. 路由跳转 (使用 replace 防止回退)
    navigate('/', { replace: true });
    
  } catch (error) {
    // 错误已由拦截器处理或在此处捕获 UI 提示
  } finally {
    setLoading(false);
  }
};

设计亮点

  • 无侵入性:组件不直接操作 localStorage
  • 类型安全:TypeScript 确保 formData 和 API 响应结构的匹配。
  • 体验优化replace: true 防止用户登录后点击“后退”回到登录页的死循环。

📝 总结与展望

通过今天的重构,我们建立了一套健壮的鉴权体系:

  1. Mock 层:真实模拟了 JWT 的生命周期,为前端测试提供了可靠环境。
  2. 网络层:Axios 拦截器实现了关注点分离,业务代码不再耦合鉴权逻辑。
  3. 状态层:Zustand persist 解决了持久化问题,配合静默验证保证了状态的有效性。

下一步挑战
有了这套体系,我们就可以放心地实现**路由守卫(Protected Routes)**了。未登录用户访问 /dashboard 等私有路径时,将被自动重定向至登录页。这将是下一篇实战的重点。