如果只关心前端实现,可以直接跳转到前端部分
或者直接查看仓库 GitHub - TransonQ/express-jwt-demo
网络上有关这方面的文章很多,但是基本都是纯前端核心代码展示,如果是为了解决业务问题,最简单的方案就是复制粘贴然后看效果。但是为了更好的验证和测试,以及更好帮助自己了解前后端分离开发过程中后端同学的视角,才有了这个项目,希望有所帮助。
项目结构
本项目为 monorepo 仓库,使用了 pnpm workspace
管理。
├───📁 backend/
│ ├───📁 node_modules/
│ │ └───...
│ ├───📁 src/
│ │ └───...
│ ├───📄 .env
│ ├───📄 README.md
│ └───📄 package.json
├───📁 frontend/
│ ├───📁 node_modules/
│ │ └───...
│ ├───📁 public/
│ │ └───...
│ ├───📁 src/
│ │ └───...
│ ├───📄 .env
│ ├───📄 README.md
│ ├───📄 eslint.config.js
│ ├───📄 index.html
│ ├───📄 package.json
│ ├───📄 tsconfig.app.json
│ ├───📄 tsconfig.json
│ ├───📄 tsconfig.node.json
│ └───📄 vite.config.ts
├───📄 .gitignore
├───📄 .prettierrc
├───📄 README.md
├───📄 package.json
├───📄 pnpm-lock.yaml
└───📄 pnpm-workspace.yaml
前端部分
- 通过
create vite
创建; - 路由使用了
react-router
v7 版本; - 简单的样式使用了
tailwindcss
v4 版本; - 使用
axios
请求库; - 使用
swr
作为上层请求库; - 使用
js-cookie
操作浏览器 cookie。
核心代码
axios 实例 src/api/ax.instance.ts
import axios, {
type AxiosError,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios';
import { CookieKeys, cookies } from '../utils/cookies';
import { postRefrehToken } from './examples.api';
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
// 创建axios实例
const ax = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
});
// 请求拦截器
ax.interceptors.request.use(onRequest, onRequestError);
// 响应拦截器
ax.interceptors.response.use(onResponse, onResponseError);
// 公共的请求拦截器
function onRequest(config: CustomAxiosRequestConfig) {
// handleOtherCustomRequest(config);
const accessToken = cookies.get(CookieKeys['ACCESS-TOKEN']);
config.headers['Authorization'] = accessToken;
return config;
}
// 公共的请求错误拦截器
function onRequestError(error: AxiosError) {
// handleOtherCustomRequestError(error);
return Promise.reject(error);
}
// 公共的响应拦截器
function onResponse(response: AxiosResponse) {
// handleOtherCustomResponse(response);
return response;
}
// 公共的响应错误拦截器
async function onResponseError(error: AxiosError) {
const originalRequest = error.config as CustomAxiosRequestConfig;
if (error.response?.status === 401 && !originalRequest?._retry) {
originalRequest._retry = true;
try {
const refreshToken = cookies.get(CookieKeys['REFRESH-TOKEN']);
if (!refreshToken) return;
const axiosResponse = await postRefrehToken(refreshToken);
const { data } = axiosResponse;
cookies.set(CookieKeys['ACCESS-TOKEN'], data.accessToken);
cookies.set(CookieKeys['REFRESH-TOKEN'], data.refreshToken);
// 重新发起请求
originalRequest.headers.Authorization = data.accessToken;
// 返回重新发起请求的结果
return ax(originalRequest);
} catch {
// 自定义处理当刷新 token 接口报错的情况,通常重定向到登录路由。
window.location.href = '/login';
}
}
// 处理其他类型的错误 ...
// handleOtherCustomError(error);
return Promise.reject(error);
}
export { ax };
这里使用一个封装的工具函数 src/utils/cookies.ts
用于维护 cookie 相关的键名:
import Cookies from 'js-cookie';
export const cookies = Cookies;
export enum CookieKeys {
'ACCESS-TOKEN' = 'jwt_demo_access_token',
'REFRESH-TOKEN' = 'jwt_demo_refresh_token',
}
后端部分
- 基于 node@22 ;
- 使用
body-parser
解析请求体; - 使用
cors
解决浏览器跨域的问题; - 使用
express
搭建服务器; - 使用
jsonwebtoken
生成和解析前后端交互的 access token 和 refresh token。
模拟数据库
为了简单模拟鉴权行为,这里用一个数组模拟数据库。
// 存储 Refresh Token 的模拟数据库
const RefreshTokensDB = [];
登录接口
登录获取 access token 和 refresh token,并且作为响应数据返回。
// 登录接口,生成 AccessToken 和 RefreshToken
app.post('/login', (req, res) => {
const { username } = req.body;
if (!username) {
return res.status(400).json({ error: '用户名是必填的' });
}
// 生成 Access Token
const accessToken = jwt.sign({ username }, ACCESS_TOKEN_SECRET, {
expiresIn: ACCESS_TOKEN_EXPIRATION,
});
// 生成 Refresh Token
const refreshToken = jwt.sign({ username }, REFRESH_TOKEN_SECRET, {
expiresIn: REFRESH_TOKEN_EXPIRATION,
});
// 存储 Refresh Token
RefreshTokensDB.push(refreshToken);
res.json({ accessToken, refreshToken });
});
刷新 token 接口
前端将 refresh_token 作为请求数据请求刷新 token 的接口获取最新的 access token 和 refresh token 。
// 刷新 Access Token 的接口
app.post('/token', (req, res) => {
const { refresh_token } = req.body;
if (!refresh_token) {
return res.status(401).json({ error: '缺少 Refresh Token' });
}
if (!RefreshTokensDB.includes(refresh_token)) {
return res.status(403).json({ error: '无效的 Refresh Token' });
}
try {
// 验证 Refresh Token
const user = jwt.verify(refresh_token, REFRESH_TOKEN_SECRET);
// 生成新的 Access Token
const accessToken = jwt.sign(
{ username: user.username },
ACCESS_TOKEN_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRATION },
);
// 生成新的 Refresh Token
const refreshToken = jwt.sign(
{ username: user.username },
REFRESH_TOKEN_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRATION },
);
// 储存 Refresh Token
RefreshTokensDB.push(refreshToken);
res.json({ accessToken, refreshToken });
} catch (err) {
res.status(403).json({ error: '无效的 Refresh Token' });
}
});
鉴权访问接口
模拟登录后且挟带正确的请求 authorization 头才能访问的接口,如果 token 失效将会出现 401 错误。如果 refresh token 也失效了返回 403 错误,则需要重新登陆。
// 测试访问受保护的路由
app.get('/protected', (req, res) => {
const authHeader = req.headers.authorization;
const token = authHeader;
if (!token) {
return res.status(401).json({ error: '缺少 Access Token' });
}
try {
// 验证 Access Token
jwt.verify(token, ACCESS_TOKEN_SECRET, (err, user) => {
if (err) {
return res.status(401).json({ error: '无效的 Access Token' });
}
req.user = user;
const query = req.query;
res.json({ message: `欢迎, ${user.username}`, query });
});
} catch (err) {
res.status(403).json({ error: '无效的 Access Token' });
}
});
扩展阅读
How to JWT vs. Opaque Tokens by sergiodxa