万字长文:从零实现 JWT 鉴权
一、JWT 鉴权概述
今天来回顾一下之前做的 JWT 鉴权。
JWT(JSON Web Token)鉴权的核心不是加密,而是无状态协议下的身份校验。
在 Express 环境下,一个完整的 JWT 鉴权流程通常包含三个关键环节:
- 颁发(Issue):用户登录成功后,服务器生成 Token。
- 存储与传递(Storage & Transmission):前端如何保存,请求时如何携带。
- 拦截与校验(Middleware):后端如何识别并解析这个字符串。
官方文档(RFC 7519) EN: JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted. 中文:JSON Web Token(JWT)是一种紧凑、URL 安全的表示声明的方式,用于在两方之间传输声明信息。JWT 中的声明被编码为 JSON 对象,用作 JSON Web Signature(JWS)结构的载荷或 JSON Web Encryption(JWE)结构的明文,使声明能够通过消息认证码(MAC)进行数字签名或完整性保护,和/或进行加密。
二、后端实现:中间件与 JWT 校验
2.1 中间件的概念与职责
Express 中间件是在请求进入路由处理、响应返回客户端之前,执行逻辑校验、数据加工、拦截等操作的函数。
官方文档(Express 官方文档) EN: Middleware functions are functions that have access to the request object (req), the response object (res), and the next middleware function in the application’s request-response cycle. The next middleware function is commonly denoted by a variable named next. 中文:中间件函数是可以访问应用请求-响应周期中请求对象(req)、响应对象(res)以及下一个中间件函数的函数。下一个中间件函数通常由名为 next 的变量表示。
在实现这个模块时,采用的是后端先行、接口先行的方式,先从后端 API 开始写。
这里有一件值得反思的工程实践:后端接口是否可用,不应该等到所有代码写完以后再去测试,效率较低。更合理的方式是在接口链路打通后,就在 Postman 里测一下是否可用。
在后端工程流程里,可以采用 JWT 官方提供的一些方法。它的调用方式是:中间件就像一道安检,校验是否携带了所需的 token,是否能从 JWT 中拿到需要的状态。
关于中间件的写法,我也是参考官方示例。有一个比较重要的函数是 next,next 用于声明当前处理完成,然后交给下一个处理程序。next 必须显式调用,否则 request 会一直处于挂起状态,无法返回 response。
官方文档(Express 官方文档) EN: The
nextfunction is a callback function that invokes the next middleware function in the stack. If the current middleware function does not end the request-response cycle, it must callnext()to pass control to the next middleware function. Otherwise, the request will be left hanging. 中文:next函数是一个回调函数,用于调用堆栈中的下一个中间件函数。如果当前中间件函数未结束请求-响应周期,则必须调用next()将控制权传递给下一个中间件函数,否则请求将处于挂起状态。
Express 中错误处理中间件必须定义 4 个参数 (err, req, res, next),只有这样才会被识别为错误捕获中间件;普通中间件/路由处理函数为 2–3 个参数,不存在“两个参数即为终止型中间件”的规则。
官方文档(Express 官方文档) EN: Error-handling middleware functions are defined the same way as other middleware functions, except with four arguments instead of three:
(err, req, res, next). 中文:错误处理中间件函数的定义方式与其他中间件函数相同,区别在于需要传入四个参数而非三个:(err, req, res, next)。
2.2 auth 中间件实现
express 中间件 auth,用于验证 JWT token 并将用户信息注入到请求对象中
import type { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
export interface AuthPayload extends jwt.JwtPayload {
userId: string;
username: string;
}
export interface AuthRequest extends Request {
user?: AuthPayload;
}
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error("FATAL ERROR: JWT_SECRET is not defined.");
}
export const auth = (req: AuthRequest, res: Response, next: NextFunction) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.sendStatus(401);
try {
const decoded = jwt.verify(token, JWT_SECRET);
if (typeof decoded === "string") {
return res.status(403).json({ message: "Token invalid" });
}
if (!decoded.userId || !decoded.username) {
return res.status(403).json({ message: "Token invalid" });
}
req.user = decoded as AuthPayload;
next();
} catch (_) {
return res.status(403).json({ message: "Token invalid" });
}
};
2.3 JWT 验证的两个核心问题
我记得这里重要的 api 是 verify,还有弄清楚 JWT 承载用户信息的部分是哪里,要回答两个问题:
1. 如何验证 JWT token?
const decoded = jwt.verify(token, JWT_SECRET);
官方文档(jsonwebtoken 官方文档) EN: The
verifyfunction takes a token, a secret or public key, and an optional callback function. It verifies the token's signature, checks if the token is expired, and decodes the payload. 中文:verify函数接收一个 token、一个密钥或公钥,以及一个可选的回调函数。它会验证 token 的签名、检查 token 是否过期,并对载荷进行解码。
jwt.verify() 的执行顺序:先对 Token 做 base64url 解码(无需密钥),再验证签名是否合法,最后检验 exp 等过期/时效声明;任何一步不通过都会抛出错误,解码一定会发生,只是非法结果不会被业务使用。
2. 如何把 JWT 内承载的用户信息注入到 req 里面?
注入发生在验证成功后:
req.user = decoded as AuthPayload;
req 的 user 属性被解密后的明文赋值,注入数据,后续路由就可以通过调用这个中间件得到 token 里包含的信息。
其实也就是中间件在后端的作用。我认为中间件是在 request 和 response 之间进行逻辑校验或数据加工。
请求 → 中间件1 → 中间件2 → 路由处理 → 响应
↑
这里做校验、加工、拦截
JWT 的核心是签名验证,而不是加密。payload 只是 base64url 编码,可以解码查看,但无法篡改,因为篡改后签名会失效。
官方文档(RFC 7519) EN: JSON Web Signature (JWS) is an integrated set of specifications for representing content secured with digital signatures or Message Authentication Codes (MACs) using JSON-based data structures. 中文:JSON Web Signature(JWS)是一套集成的规范集,用于使用基于 JSON 的数据结构表示通过数字签名或消息认证码(MAC)保护的内容。
三、数据层设计:MongoDB 与 Mongoose
把中间件处理、也就是 JWT 校验做好后,就开始设计数据库集合(Collection)的 schema,确定要存入哪些数据、登录需要哪些字段,把 schema 字段和加密逻辑配置好。
import mongoose, { Document, Schema } from "mongoose";
import bcrypt from "bcrypt";
export interface IUser extends Document {
username: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
const userSchema = new Schema<IUser>(
{
username: {
type: String,
required: [true, "用户名不能为空"],
unique: true,
trim: true,
minlength: [3, "用户名至少 3 个字符"],
maxlength: [20, "用户名最多 20 个字符"],
},
password: {
type: String,
required: [true, "密码不能为空"],
minlength: [6, "密码至少 6 个字符"],
select: false,
},
},
{
timestamps: true,
},
);
userSchema.pre("save", async function (next) {
if (!this.isModified("password")) {
return next();
}
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
});
const User = mongoose.model<IUser>("User", userSchema);
export default User;
其中,密码加密的 pre("save") 中间件负责密码哈希。
官方文档(Mongoose 官方文档) EN: Mongoose schemas support pre and post hooks for middleware functions. These hooks are functions that are executed before or after a certain event (like
save,find, etc.) occurs. 中文:Mongoose 的 Schema 支持用于中间件函数的 pre 和 post 钩子。这些钩子是在特定事件(如save、find等)发生之前或之后执行的函数。
3.1 关于 MongoDB 和 Mongoose
MongoDB 本身是 schemaless(无模式) 的,意思是:
- 你可以往同一个集合里存完全不同的结构
- 没有强制的字段类型、必填校验
- 没有自动的钩子(如密码加密)
这很灵活,但大型项目里容易造成:
- 数据混乱(有的文档有
username,有的没有) - 业务逻辑散落各处
- 难以维护
官方文档(MongoDB 官方文档) EN: MongoDB is a document-oriented database program. Classified as a NoSQL database program, MongoDB uses JSON-like documents with optional schemas. 中文:MongoDB 是一个面向文档的数据库程序。作为 NoSQL 数据库程序,MongoDB 使用具有可选模式的类 JSON 文档。
Mongoose 的作用就是给 MongoDB 加上“规矩”:
- 定义数据结构(Schema)
- 自动验证类型、必填、长度等
- 提供钩子(pre/post)自动处理逻辑(如密码加密)
- 封装常用的 CRUD 方法
官方文档(Mongoose 官方文档) EN: Mongoose is a MongoDB object modeling tool designed to work in an asynchronous environment. Mongoose provides a straight-forward, schema-based solution to model your application data. 中文:Mongoose 是一个设计用于异步环境的 MongoDB 对象建模工具。Mongoose 提供了一种直观的、基于模式的解决方案来为你的应用数据建模。
前端 → 后端控制器(Controller) → 服务层(Service) → Model → MongoDB
- Model 是数据层(Data Layer):它封装了所有与数据库直接交互的逻辑
- 业务逻辑层(Service) 调用 Model 的方法来读写数据
- 控制器(Controller) 处理 HTTP 请求,调用 Service
- 这样分层的好处:替换数据库时只需改动 Model 层,业务逻辑不变
Model 设计的必要性:
- 集中管理数据规则(验证、加密、默认值)
- 避免在多个地方重复写密码加密、字段校验的代码
- 保证数据一致性
四、业务逻辑层:Controller 实现
于是在 model 定义好以后,我们可以写好 controller,对应业务逻辑。这一块比较核心,代码也比较多,我贴出示例的完整代码参考思路:
// ============================================
// authController.ts - 业务逻辑
// ============================================
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
import { Request, Response } from "express";
import User from "../models/userModel.js";
import { AuthRequest } from "../middleware/auth.js";
const JWT_SECRET = process.env.JWT_SECRET!;
// 辅助函数:生成 JWT 并组装返回数据
const buildAuthPayload = (user: any) => {
const token = jwt.sign(
{ userId: String(user._id), username: user.username },
JWT_SECRET,
{ expiresIn: "24h" }
);
return {
token: token,
user: {
id: String(user._id),
username: user.username
}
};
};
// ========== 登录 ==========
export const login = async (req: Request, res: Response) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ message: "用户名和密码不能为空" });
}
const user = await User.findOne({ username }).select("+password");
if (!user) {
return res.status(401).json({ message: "用户名或密码错误" });
}
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).json({ message: "用户名或密码错误" });
}
return res.status(200).json(buildAuthPayload(user));
};
// ========== 注册 ==========
export const register = async (req: Request, res: Response) => {
const { username, password } = req.body ?? {};
if (!username || !password) {
return res.status(400).json({ message: "用户名和密码不能为空" });
}
if (password.length < 6) {
return res.status(400).json({ message: "密码至少6个字符" });
}
try {
const existingUser = await User.findOne({ username });
if (existingUser) {
return res.status(409).json({ message: "用户名已存在" });
}
const user = await User.create({ username, password });
// ↑ 保存时自动触发 pre("save") 钩子加密密码
return res.status(201).json({
message: "注册成功",
...buildAuthPayload(user)
});
} catch (error) {
if (error instanceof Error) {
return res.status(400).json({ message: error.message });
}
return res.status(500).json({ message: "服务器错误" });
}
};
// ========== 获取当前用户 ==========
export const me = async (req: AuthRequest, res: Response) => {
try {
const userId = req.user?.userId;
if (!userId) {
return res.sendStatus(401);
}
const user = await User.findById(userId).select("_id username");
if (!user) {
return res.sendStatus(401);
}
return res.status(200).json({
user: {
id: String(user._id),
username: user.username
}
});
} catch (error) {
return res.status(500).json({ message: "服务器错误" });
}
};
五、路由层:接口注册与请求流程
最后编写注册、登录相关路由。
1. 主应用挂载路由模块
app.use("/api/auth", authRoutes)
↓
2. 请求进入,匹配前缀 "/api/auth"
↓
3. 进入 authRoutes 模块,匹配具体路径
router.post("/login", login)
↓
4. 完整路径 = "/api/auth/login"
↓
5. 执行对应的控制器函数
import "dotenv/config";
import { Router } from "express";
import { login, me, register } from "../controllers/authController.js";
import { auth } from "../middleware/auth.js";
const router: Router = Router();
router.post("/login", login);
router.post("/register", register);
router.get("/me", auth, me);
export default router;
官方文档(Express 官方文档) EN: A router is an isolated instance of middleware and routes. You can use a router to group related routes together and apply middleware to a subset of your application's routes. 中文:Router 是中间件和路由的独立实例。你可以使用 Router 将相关路由分组,并将中间件应用到应用程序路由的子集上。
导入依赖以后,创建全局 Router 实例,定义路由并进行后端注册,目的是之后前端路由请求可以匹配到后端,后端也就调用相关的 controller 处理数据。
以 router.get("/me", auth, me) 为例:
- 方法:GET
-
路径:
/me -
中间件链:
auth→me - 场景:获取当前登录用户的信息
-
执行流程:
- 请求先进入
auth中间件 -
auth验证 token,把用户信息挂到req.user - 验证通过后调用
next(),进入me控制器 -
me控制器从req.user读取信息返回
- 请求先进入
5.1 后端完整请求流程图
以上可以得到后端的完整请求流程图:
┌─────────────────────────────────────────────────────────────────┐
│ 注册流程 │
├─────────────────────────────────────────────────────────────────┤
│ POST /api/auth/register { username, password } │
│ ↓ │
│ authRoutes → router.post("/register", register) │
│ ↓ │
│ register 控制器 │
│ ├── 验证 username/password 存在 │
│ ├── 验证密码长度 ≥ 6 │
│ ├── 检查用户名是否已存在 │
│ ├── User.create({ username, password }) │
│ │ ↓ │
│ │ pre("save") 钩子: bcrypt 哈希密码 │
│ │ ↓ │
│ │ 存入 MongoDB │
│ ├── buildAuthPayload() → jwt.sign() 生成 token │
│ └── 返回 { token, user } │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 登录流程 │
├─────────────────────────────────────────────────────────────────┤
│ POST /api/auth/login { username, password } │
│ ↓ │
│ authRoutes → router.post("/login", login) │
│ ↓ │
│ login 控制器 │
│ ├── 验证 username/password 存在 │
│ ├── User.findOne({ username }).select("+password") │
│ ├── bcrypt.compare(明文密码, 哈希密码) │
│ ├── buildAuthPayload() → jwt.sign() 生成 token │
│ └── 返回 { token, user } │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 获取当前用户流程 │
├─────────────────────────────────────────────────────────────────┤
│ GET /api/auth/me │
│ Header: Authorization: Bearer <token> │
│ ↓ │
│ authRoutes → router.get("/me", auth, me) │
│ ↓ │
│ auth 中间件 │
│ ├── 提取 token │
│ ├── jwt.verify(token, JWT_SECRET) │
│ └── req.user = { userId, username } │
│ ↓ │
│ me 控制器 │
│ ├── userId = req.user?.userId │
│ ├── User.findById(userId).select("_id username") │
│ └── 返回 { user: { id, username } } │
└─────────────────────────────────────────────────────────────────┘
5.2 加密与验证对照表
关于加密与验证:
| 环节 | 位置 | 方法 | 目的 |
|---|---|---|---|
| 密码加密 | userModel.ts |
bcrypt.hash() |
注册时把明文密码转成哈希存储 |
| 密码比对 |
login 控制器 |
bcrypt.compare() |
登录时验证用户输入的密码 |
| JWT 签发 | buildAuthPayload() |
jwt.sign() |
登录/注册成功后生成 token |
| JWT 验证 |
auth 中间件 |
jwt.verify() |
后续请求验证 token 有效性 |
实际上,这个时候应该可以测一测后端接口了,用 Postman 测试一下后端已启动服务时是否能接通。我之前是前端也写了才去测,觉得效率很低。
六、前端实现:路由与状态管理
我来看一下前端的 router 导航
import React from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import App from "../App";
import { LoginPage } from "../components/Login";
import { useAuth } from "../contexts/authContext";
const RequireAuth = ({ children }: { children: React.ReactElement }) => {
const { user, isLoading } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (!user) return <Navigate to="/login" replace />;
return children;
};
const RedirectIfAuthenticated = ({
children,
}: {
children: React.ReactElement;
}) => {
const { user, isLoading } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (user) return <Navigate to="/wiki" replace />;
return children;
};
const AppRoutes: React.FC = () => {
return (
<Routes>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route
path="/login"
element={
<RedirectIfAuthenticated>
<LoginPage />
</RedirectIfAuthenticated>
}
/>
<Route
path="/wiki"
element={
<RequireAuth>
<App />
</RequireAuth>
}
/>
<Route
path="/wiki/:docId"
element={
<RequireAuth>
<App />
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
);
};
export default AppRoutes;
官方文档(React Router 官方文档) EN: React Router enables “client side routing” for React apps. It allows you to build single-page applications with navigation that doesn’t require a page refresh. 中文:React Router 为 React 应用提供“客户端路由”能力,允许你构建具有导航功能且无需页面刷新的单页应用。
后端已经通了,前端的作用是发起请求,这里的路由导航只是跳转页面,并且前端服务要遵循所有业务功能都在用户认证通过后才能使用的原则,也就是 service 的服务调用逻辑,这些是前端要做的事情。
6.1 前端 API 服务层
前端 service 封装好 API 调用层,封装与后端认证接口的通信逻辑。
以 auth 为例
import apiClient from "./client";
import type { AuthUser } from "../contexts/authContext";
interface AuthResponse {
token: string;
user: AuthUser;
}
export const loginApi = (data: { username: string; password: string }) =>
apiClient.post<AuthResponse>("/api/auth/login", data);
export const registerApi = (data: { username: string; password: string }) =>
apiClient.post<AuthResponse>("/api/auth/register", data);
export const meApi = () => apiClient.get<{ user: AuthUser }>("/api/auth/me");
6.2 前后端数据流
完整的数据流
业务代码调用 apiClient.post("/api/auth/me")
↓
【请求拦截器】
从 localStorage 读取 token
添加 Authorization: Bearer <token>
↓
发送请求到后端
↓
后端验证 token
↓
┌───────────────────────────────────────┐
│ token 有效 → 返回 200 + 用户数据 │
│ token 无效/过期 → 返回 401 │
└───────────────────────────────────────┘
↓
【响应拦截器】
↓
┌───────────────────────────────────────┐
│ 200 → 直接返回 response │
│ 401 → 清除 token,跳转 /login │
└───────────────────────────────────────┘
↓
业务代码拿到结果
6.3 全局认证状态管理
之前我在想项目逻辑要求登录后才能使用相关功能,也就是原来无登录状态的所有路由,都要在登录路由保护下才能访问。怎样让这些接口自动带上鉴权,认为实现起来比较难。
其实也不是很难,可以用一个 authContext 登录状态的全局状态分发。
import React, { createContext, useEffect, useState } from "react";
import { meApi } from "../services/auth";
export interface AuthUser {
id: string;
username: string;
}
export interface AuthContextType {
// 1. 核心状态:当前用户是谁?
user: AuthUser | null;
// 2. 状态:是否正在初始化(从 LocalStorage 加载中)?
// 提示:这能防止页面在检查 Token 时闪现“未登录”状态
isLoading: boolean;
// 3. 方法:登录成功后调用的函数
// 它需要接收后端给的 token 和 user 对象
login: (token: string, user: AuthUser) => void;
// 4. 方法:退出登录
// 它需要清理 LocalStorage 和 context 状态
logout: () => void;
}
export const AuthContext = createContext<AuthContextType | undefined>(
undefined,
);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const initAuth = async () => {
try {
const token = localStorage.getItem("token");
if (!token) {
return;
}
const res = await meApi();
setUser(res.data.user);
} catch (_) {
localStorage.removeItem("token");
setUser(null);
} finally {
// 保证无论成功/失败都结束 loading
setIsLoading(false);
}
};
initAuth();
}, []);
const login = (token: string, user: AuthUser) => {
localStorage.setItem("token", token);
setUser(user);
};
const logout = () => {
localStorage.removeItem("token");
setUser(null);
window.location.href = "/login";
};
return (
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = React.useContext(AuthContext);
if (!context) throw new Error("useAuth must be used within an AuthProvider");
return context;
};
存储方案上选择了 localStorage,因为 sessionStorage 在会话关闭、页面关闭后重新打开需要重新登录,使用起来比较麻烦。
但需要注意:localStorage 易受到 XSS 攻击,生产环境更推荐使用 httpOnly Cookie 存储 JWT。(这一块后续我要了解一下)
这里涉及请求头、axios 实例配置(client.ts)、前端请求拦截器(axios)、前端注册接口、文档相关接口、全局状态管理、登录状态管理等等。
最后在前端导航要做重定向,默认定向到登录页,实现UX上的交互。
六、小结
其实这里的内容是前天做的,中间耽搁了一会儿,当时实现的时候觉得困难重重——主要是因为之前没有建立好后端实现的思路,具体实现的细节,并且也没有相对应的概念。
从项目的角度来讲,我更深体会到了前后端协作,也就是之前看到的前端要懂业务,虽说这里有关前端的细节写得不是太多,但是前端要能知道后端要做什么,从产品的角度来讲每一个开发者都要有全栈能力,但是从公司的角度来说业务体系庞大才拆分的前端与后端等等岗位。
从面试的角度,这里也涉及到很多JWT鉴权细节上的考量,随便深挖就会揪出更多底层原理,例如我随意问一问:什么是 JWT?为什么要用 JWT?JWT 和 Session 的区别?登录流程:如何颁发 Token?请求流程:如何校验 Token?前端怎么存?怎么带?中间件在 JWT 里做什么?
啊哈......现在一看又觉得自己不懂了,只是没有把这些表达再深入内化一下,先慢慢来,慢慢深挖学习更多内容。
坚持学习,坚持反思,加油!
特别声明:本次代码实现仅仅是能跑通功能,并不是优雅的做法,存在设计等层面的缺陷,还请见谅。限于个人经验,文中若有疏漏,还请不吝赐教。