我的项目实战(五) - JWT 登录与安全、可维护的用户认证系统

0 阅读8分钟

在现代 Web 开发中,用户身份认证是一个绕不开的核心环节。随着单页应用(SPA)和前后端分离架构的普及,传统的 Cookie-Session 认证模式逐渐被基于 Token 的无状态认证所取代。其中,JWT(JSON Web Token)因其简洁、自包含和跨域友好的特性,成为当前最主流的身份令牌方案。

我的项目实战将带你一步步剖析一个完整的 JWT 登录流程,涵盖 Token 的生成与验证机制Axios 拦截器的设计思想用户状态的本地持久化管理 以及 表单状态的合理组织方式。让我们深入探讨每一个设计背后的“为什么”,帮助你真正掌握这套体系的底层逻辑。


一、JWT 是什么?它解决了哪些问题?

HTTP 协议本身是无状态的。这意味着服务器不会记住你是谁——每次请求都像是第一次见面。为了实现“登录后保持登录状态”的功能,我们必须引入某种机制来标识用户身份。

1.1 传统方案:Cookie + Session

早期的做法是在服务端维护一个 Session 存储(如内存或 Redis),登录成功后返回一个 Session ID,浏览器通过 Cookie 自动携带这个 ID 发送给服务器,从而识别用户。

这种方式的问题在于:

  • 依赖服务端存储,难以水平扩展;
  • 跨域场景下 Cookie 受限;
  • 不适合微服务或多端共用 API 的架构。

1.2 新时代选择:JWT(JSON Web Token)

JWT 把用户信息直接编码进一个字符串令牌中,格式为 xxxxx.yyyyy.zzzzz,由三部分组成:

  • Header:声明类型和加密算法;
  • Payload:存放用户数据(如 id、name 等)和过期时间;
  • Signature:使用密钥对前两部分签名,防止篡改。

示例:当你登录成功时,服务器签发这样一个 token:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

它实际上包含了你的用户信息 { "id": 1, "name": "admin" } 和有效期。

✅ 优点

  • 无状态:服务器无需保存 session,适合分布式部署;
  • 自包含:token 自带用户信息,减少数据库查询;
  • 跨域友好:可通过 Authorization 请求头传输,不受同源策略限制。

⚠️ 注意事项

  • 不要存放敏感信息:JWT 只是 Base64 编码,并非加密,任何人都可以解码查看内容;
  • 设置合理过期时间:避免长期有效的 token 被盗用;
  • 配合 HTTPS 使用:防止中间人窃取 token。

二、Token 的签发与验证:一次完整的交互过程

来看看我们的项目,拆解一次登录流程中的关键步骤。

2.1 登录接口如何签发 Token?

当用户提交用户名密码后,后端需要做以下几件事:

  1. 校验账号密码是否正确;
  2. 如果正确,构造一个 payload 对象;
  3. 使用 jwt.sign() 方法生成 token;
  4. 返回给前端。
const token = jwt.sign(
  { user: { id: 1, name: 'admin', avatar: '...' } },
  'bld1235swsad!', // secret 密钥
  { expiresIn: 86400 * 7 } // 7天过期
);

这里有几个关键点值得说明:

🔐 Secret 密钥的安全性

密钥必须足够复杂且保密。一旦泄露,攻击者就可以伪造任意用户的 token。建议将其配置在环境变量中,而不是写死在代码里。

🕰 过期时间的权衡

设得太短,用户体验差(频繁重新登录);设得太长,安全风险高。常见做法是结合 Refresh Token 机制,但我们今天先聚焦基础流程。

📦 Payload 放什么?

只放必要字段,比如 idrolename。不推荐放入权限列表等大对象,会导致 token 过大影响性能。


2.2 如何验证 Token 的合法性?

前端每次发起请求时,都需要在请求头中带上 token:

Authorization: Bearer <your-token-here>

而后端收到请求后,会调用 jwt.verify(token, secret) 来解析并校验 token 是否有效。如果失败(如过期或被篡改),则返回 401 错误。

但在我们的 mock 实现中,有一个小细节需要注意:

const token = req.headers['Authorization'].split(" ")[1];

这行代码假设了 header 中一定存在 Authorization 字段,并且是以 Bearer 开头的。如果没有做容错处理,在真实环境中容易出错。还可以有更健壮的方式:

const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
  return { code: 401, message: '未提供 token' };
}
const token = authHeader.slice(7); // 去掉 "Bearer "

此外,jwt.decode() 只是解码而不验证签名,应仅用于调试。生产环境必须使用 jwt.verify() 才能确保安全性。


三、Axios 拦截器:让请求更智能

在大型项目中,不可能每个请求都手动添加 token。我们需要一种全局机制来统一处理认证逻辑——这就是 Axios 拦截器 的作用。

3.1 请求拦截器:自动注入 Token

axios.interceptors.request.use(config => {
  const token = useUserStore.getState().token;
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

这段代码的作用是:在每一个请求发出之前,检查是否有 token,如果有就加到请求头里。

设计考量

  • 集中管理:避免重复书写 headers: { Authorization: ... }
  • 动态获取:从 Zustand Store 中读取最新状态,保证实时性;
  • 条件判断:只有登录状态下才添加,不影响公共接口。

但这里有个潜在问题:useUserStore.getState() 是 Zustand 提供的静态方法,可以在非组件环境中安全调用。如果是其他状态库(如 Redux)可能需要额外封装才能做到这一点。

3.2 响应拦截器:统一错误处理

axios.interceptors.response.use(
  res => {
    if (res.status !== 200) {
      console.error('网络错误', res);
      return Promise.reject(res);
    }
    return res.data; // 直接返回 data
  },
  error => {
    if (error.response?.status === 401) {
      // 清除用户信息,跳转登录页
      useUserStore.getState().logout();
    }
    return Promise.reject(error);
  }
);

响应拦截器的好处包括:

  • 简化数据结构:后端返回 { code, message, data }减少一层包裹,我们只需要 data
  • 统一异常捕获:所有请求都能自动处理 401、500 等状态码;
  • 增强可维护性:未来要加 loading 提示、重试机制都可以在这里统一实现。

四、用户状态管理:Zustand + Persist 的轻量级方案

前端需要记住“当前是谁”、“有没有登录”。这就涉及到全局状态管理。

4.1 为什么选 Zustand 而不是 Redux?

Redux 功能强大但模板代码多,学习成本高。而 Zustand 以其极简 API良好的 TypeScript 支持赢得了越来越多开发者的青睐。

我们定义了一个 UserState 接口来描述用户相关的状态:

interface UserState {
  token: string;
  user: User | null;
  isLogin: boolean;
  login: (credentials: Credentail) => Promise<void>;
}

然后通过 create() 创建 store,并用 persist 中间件实现本地持久化。

4.2 持久化的意义:刷新页面不失效

默认情况下,页面刷新后 JavaScript 状态会被清空。但我们希望用户登录后即使刷新也能保持登录状态。

persist((set) => ({ ... }), {
  name: 'user-store',
  partialize: (state) => ({
    token: state.token,
    user: state.user,
    isLogin: state.isLogin
  })
})

partialize 表示只持久化指定字段,避免不必要的数据写入 localStorage。

💡 小技巧:选择性持久化

比如 login 函数是不需要存的,因为它只是行为逻辑。只保留“可序列化”的数据字段即可。

⚠️ 安全提醒

虽然方便,但把 token 存在 localStorage 也有风险(XSS 攻击)。更安全的做法是配合 httpOnly cookie 使用,但这超出了本文范围。


五、表单状态管理:React 中的受控组件模式

登录页面本质上是一个表单操作。React 推荐使用“受控组件”来管理输入状态。

5.1 受控组件 vs 非受控组件

类型特点适用场景
受控组件输入值由 React state 控制复杂表单、需实时校验
非受控组件值由 DOM 自己管理,通过 ref 获取简单输入、上传文件

我们的登录表单采用了典型的受控模式:

const [formData, setFormData] = useState<Credentail>({
  name: "",
  password: ""
});

<Input 
  value={formData.name}
  onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
/>

优势

  • 数据流清晰:视图 → 状态 → 视图;
  • 易于校验:可在 onChange 或 onSubmit 时进行空值判断;
  • 支持重置:只需 setFormData(initialValue)

优化建议

对于字段较多的表单,可以考虑使用 useReducer 或第三方库(如 React Hook Form)来降低复杂度。


六、整体流程串联:一次登录发生了什么?

现在我们把所有环节串起来,看看从点击“登录”按钮开始,到底经历了什么:

  1. 用户填写用户名密码,点击登录;
  2. handleLogin 触发,调用 useUserStore.login()
  3. doLogin 发起 POST 请求 /api/auth/login
  4. Mock 接口验证账号密码,签发 JWT 并返回;
  5. Store 更新状态:保存 token、user、isLogin=true;
  6. Zustand 自动同步到 localStorage;
  7. Axios 拦截器从此以后自动带上 token;
  8. 页面跳转到首页,完成登录。

整个过程没有刷新,用户体验流畅,且具备持久性和安全性。


七、总结与延伸思考

✅ 我们实现了什么?

  • 基于 JWT 的无状态认证;
  • Axios 拦截器统一管理请求/响应;
  • Zustand 实现轻量级全局状态 + 持久化;
  • 受控组件管理表单输入;
  • 清晰的分层结构:API 层、Store 层、UI 层。

❓还可以怎么改进?

方向改进建议
安全性使用 httpOnly + Secure Cookie 存储 token
体验优化添加 loading 状态、输入防抖、错误提示
错误恢复实现 token 刷新机制(Refresh Token)
权限控制在路由层根据 role 字段做访问限制
类型安全统一 API 响应格式,提取 Response 泛型

结语

JWT 不是一种银弹,但它确实是当前最适合前后端分离项目的认证方案之一。关键在于理解其原理,合理使用工具链,构建出既安全又易维护的系统。

希望这篇文章能帮你建立起对用户认证系统的完整认知。下次当你面对“登录怎么做”这个问题时,心里已经有了答案。