引言:为什么你的网站需要"记住"用户?
打开一个网站,登录一次,关闭浏览器再打开------咦,还是登录状态?网站是怎么"记住"你的?
答案藏在两个字里:鉴权。
HTTP 协议本身是无状态(Stateless) 的。什么意思?简单说,服务器每次接到你的请求,都当作第一次见到你------它记不住你是谁。如果你刷新一次页面就要重新输一次密码,这体验恐怕没人受得了。
所以我们需要一种机制:用户登录之后,后续的每一次请求都能证明"我是我"。这就是身份认证要解决的问题。
目前主流的方案有两类:一种是基于服务端 Session 的 Cookie 机制,另一种就是今天的主角------JWT(JSON Web Token) 。
一、JWT 是什么?一张自带身份的"电子通行证"
JWT 的全称是 JSON Web Token。它本质上是一个经过加密签名的字符串,由服务器在用户登录成功后颁发给客户端。
你可以这样理解:你登录成功了,服务器没有在它自己那边给你记小本本(Session),而是直接给你发了一张"电子通行证"。这张证里写明了"你是谁""你有什么权限"。以后你每次来访问,只要出示这张证,服务器验证一下真伪,就放你进去了。
1.1 JWT 长什么样?
一个典型的 JWT 是这样的字符串:
text
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4ifQ.4Rih-Jg6X8Z0X3yZ7Jw3X7yZ7Jw3X7yZ7Jw3X7yZ7Jw
- Header(头部) :相当于"说明书",告诉别人这张证是什么类型、用什么算法签的名。
- Payload(载荷) :真正存放信息的地方,比如用户ID、用户名、角色等。
- Signature(签名) :防篡改的关键 。服务器用只有自己知道的密钥,对前两部分进行加密计算,生成签名。
这三部分各司其职:Header 和 Payload 负责"装信息",Signature 负责"保安全"。
1.2 JWT 的认证流程
- 用户提交凭证:用户在登录页输入用户名和密码,前端发送到服务器。
- 服务器验证并签发 :服务器核对用户名密码。如果正确,用
jwt.sign()方法生成一个 JWT 令牌,返回给前端。 - 前端存储令牌 :前端拿到 JWT 后存起来(通常存在
localStorage里)。 - 后续请求携带令牌 :之后每次请求需要权限的接口,前端都在 HTTP 请求头里带上这个 JWT。服务器收到后用
jwt.verify()方法验证令牌的真伪和有效期。
二、JWT 对比传统 Session:为什么选择 JWT?
在 JWT 之前,主流方案是 Cookie + Session:
- 用户登录后,服务器在内存中创建一个 Session 对象,记录用户信息。
- 服务器把一个
sessionId写入浏览器的 Cookie 里。 - 之后每次请求,浏览器自动带上这个 Cookie,服务器根据
sessionId找到对应的 Session,确认用户身份。
这个方案在单台服务器上运行良好,但到了分布式系统就会出问题------用户第一次请求落在服务器 A,Session 存在 A 的内存里;第二次请求被负载均衡转发到了服务器 B,B 的内存里没有这个 Session,用户就被"踢出去了"。
JWT 的优势恰恰在这里:它是自包含的 。用户信息直接写在 Token 里,任何一台服务器拿到 Token 都能自己解码验证,不需要去问别的服务器。这就天然适合分布式和微服务架构。
| 对比维度 | Session-Cookie | JWT |
|---|---|---|
| 服务端存储 | 需要存 Session | 不需要 |
| 分布式支持 | 需要共享存储 | 天然支持 |
| 跨域 | 受限 | 友好 |
| 信息携带 | 仅 sessionId | 可携带用户信息 |
三、实战:用 React + Zustand + JWT 搭建登录系统
理论说完了,我们来看一个完整的实战项目。技术栈包括:
3.1 第一步:Mock 后端------签发 JWT
在真正的项目中,JWT 的签发和验证由后端服务器完成。但在开发阶段,我们可以用 Mock 来模拟后端行为。
先安装 jsonwebtoken 包:
bash
npm install jsonwebtoken
然后创建一个 Mock 登录接口:
javascript
javascript
import jwt from 'jsonwebtoken';
// 这个密钥只有服务器知道,绝对不能泄露
const secret = 'secret819!$';
// Mock 接口配置
export default [
{
url: '/api/login',
method: 'post',
response: (req) => {
const { username, password } = req.body;
// 验证用户名密码
if (username !== 'admin' || password !== '123456') {
return {
code: -1,
message: '用户名或密码错误'
};
}
// 验证通过,签发 JWT
const token = jwt.sign(
{
user: username,
role: 'admin'
},
secret,
{
expiresIn: 86400 // 有效期 24 小时,单位秒
}
);
return {
code: 0,
user: { username },
token: token
};
}
}
];
这里有几个关键点:
jwt.sign()方法接收三个参数:要存放的数据(Payload)、密钥、配置项(如过期时间)。- 密钥(Secret) 是服务器的"底牌",必须保密。如果泄露,攻击者就能伪造任意用户的 Token。
- 过期时间 是一种安全措施------就算 Token 被窃取,也只在有限时间内有效。
3.2 第二步:受保护接口------验证 JWT
有了签发的接口,还得有一个需要验证 Token 才能访问的接口:
javascript
kotlin
{
url: '/api/repo',
method: 'get',
response: (req) => {
// 从请求头中提取 Token
const authHeader = req.headers['authorization'];
if (!authHeader) {
return { code: 401, msg: '未携带 token' };
}
const token = authHeader.split(' ')[1]; // 去掉 "Bearer " 前缀
try {
// 验证 Token
const decoded = jwt.verify(token, secret);
return {
code: 0,
data: decoded.user
};
} catch (err) {
return {
code: 401,
msg: 'Invalid token'
};
}
}
}
jwt.verify() 方法会做两件事:
- 验证签名:用密钥重新计算签名,比对是否一致。不一致说明 Token 被篡改了。
- 检查过期:如果 Token 超过了设定的有效期,也会报错。
3.3 第三步:Zustand------管理登录状态
登录状态需要在整个应用中共享------导航栏要显示用户名,受保护页面要判断是否允许访问。这就需要状态管理。
Zustand 是一个轻量级的 React 状态管理库。相比 Redux,它不需要写大量的样板代码,API 非常简洁。
先安装:
bash
npm install zustand
然后创建用户状态 Store:
javascript
javascript
import { create } from 'zustand';
export const useAuthStore = create((set) => ({
// 状态:从 localStorage 恢复登录信息
token: localStorage.getItem('token') || '',
user: JSON.parse(localStorage.getItem('user')) || null,
// 动作:登录
setAuth: ({ token, user }) => {
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
set({ token, user });
},
// 动作:登出
logout: () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
set({ token: '', user: null });
}
}));
create() 函数接收一个回调,回调的参数 set 用来更新状态。我们在 setAuth 和 logout 中同时更新了 Zustand 状态和 localStorage,这样即使刷新页面,登录状态也不会丢失。
在组件中使用这个 Store:
jsx
javascript
import { useAuthStore } from '../store/user';
function Nav() {
// 只订阅 token,组件只在 token 变化时重新渲染
const token = useAuthStore(state => state.token);
const logout = useAuthStore(state => state.logout);
return (
<nav>
{!token && <Link to="/login">登录</Link>}
{token && <button onClick={logout}>退出</button>}
</nav>
);
}
Zustand 的 useAuthStore(state => state.token) 这种写法叫做选择器(Selector) ,它让组件只订阅自己需要的状态片段,避免不必要的重新渲染。
3.4 第四步:Axios 拦截器------自动携带 Token
每次请求都要手动从 localStorage 取 Token 再塞到请求头里,太麻烦了。Axios 的请求拦截器 可以帮我们自动完成这件事:
javascript
javascript
import axios from 'axios';
const instance = axios.create({
baseURL: '/api',
timeout: 5000
});
// 请求拦截器:在请求发出前自动添加 Token
instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
});
export default instance;
这里的 Bearer 是一个约定前缀,表示这是一个 Bearer Token。大多数后端框架都认识这个格式。
封装登录 API:
javascript
javascript
import axios from './config';
export const login = async (data) => {
const res = await axios.post('/login', data);
return res.data;
};
3.5 第五步:路由守卫------保护需要登录的页面
有些页面只有登录用户才能访问,比如个人中心、支付页面。我们需要一个路由守卫 来做拦截:
jsx
javascript
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../store/user';
function RequireAuth({ children }) {
const token = useAuthStore(state => state.token);
// 没有 token,说明未登录,跳转到登录页
if (!token) {
return <Navigate to="/login" replace />;
}
// 已登录,正常渲染子组件
return children;
}
export default RequireAuth;
在路由配置中使用:
jsx
javascript
import RequireAuth from './components/RequireAuth';
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/pay" element={
<RequireAuth>
<Pay />
</RequireAuth>
} />
</Routes>
</Router>
);
}
这样,当未登录的用户访问 /pay 时,会被自动重定向到 /login。
3.6 第六步:登录页面------完整流程串联
最后看登录页面的实现,把前面的所有环节串起来:
jsx
javascript
import { useNavigate } from 'react-router-dom';
import { login } from '../api';
import { useAuthStore } from '../store/user';
function Login() {
const navigate = useNavigate();
const setAuth = useAuthStore(state => state.setAuth);
const [formData, setFormData] = useState({ username: '', password: '' });
const handleLogin = async (e) => {
e.preventDefault();
try {
const res = await login(formData);
if (res.code === 0) {
// 登录成功:更新 Zustand 状态 + localStorage
setAuth({ token: res.token, user: res.user });
// 跳转到首页
navigate('/');
} else {
alert(res.message || '登录失败');
}
} catch (err) {
console.error(err);
alert('登录失败,请稍后重试');
}
};
return (
<form onSubmit={handleLogin}>
<input name="username" onChange={...} />
<input name="password" type="password" onChange={...} />
<button type="submit">登录</button>
</form>
);
}
四、关于 Token 存储的安全提醒
在实战代码中,我们把 Token 存到了 localStorage 里。这在开发中很常见,但有必要了解其中的安全风险:
localStorage 里的数据可以被同一域名下的任何 JavaScript 代码 读取。如果网站存在 XSS(跨站脚本攻击) 漏洞,攻击者就能注入恶意脚本,偷走 Token,然后冒充用户。
更安全的做法是把 Token 放在 HttpOnly Cookie 里------这种 Cookie 无法被 JavaScript 读取,只能由浏览器自动在请求中携带,能有效防御 XSS 导致的 Token 泄露。
不过 HttpOnly Cookie 也有自己的问题(比如需要防范 CSRF 攻击)。选择哪种方案,要根据项目的安全等级来决定。
五、总结
我们把整个流程再梳理一遍:
- 用户登录 :输入用户名密码,前端调用
/api/login。 - 服务器签发 JWT :验证通过后,用
jwt.sign()生成 Token 返回。 - 前端存储 :用 Zustand 管理 Token 和用户信息,同时写入
localStorage做持久化。 - 自动携带 :Axios 请求拦截器在每个请求的 Header 中自动带上
Authorization: Bearer <token>。 - 服务器验证 :受保护接口用
jwt.verify()验证 Token 的有效性。 - 路由守卫:未登录用户访问受保护页面时自动跳转到登录页。
这套流程的核心思想是:服务器不存状态,只负责签发和验证 Token;客户端负责保管和携带 Token 。正是这种"无状态"的设计,让 JWT 在分布式系统中游刃有余。
理解了这些,你不仅能用 JWT 做登录,还能举一反三,用它来处理 API 鉴权、单点登录等更多场景。