用户认证与授权
密码哈希、JWT、角色权限——保护你的 API
🎯 学习目标
学完本章,你将能够:
- 使用 bcrypt 对密码进行安全哈希存储
- 实现基于 JWT 的注册、登录和令牌刷新流程
- 编写认证中间件保护受限路由
- 理解 RBAC 角色权限控制模型,能实现基本的权限检查
- 了解 OAuth 第三方登录的基本流程
你有没有想过
为什么你登录淘宝之后,刷新页面还是登录状态?别人为什么不能以你的身份买东西?
这背后涉及两个核心概念:认证(Authentication)——"你是谁";授权(Authorization)——"你能做什么"。
这一章你将学会:用 bcrypt 安全地存储密码、用 JWT 实现无状态认证、用 RBAC 控制角色权限。
认证 vs 授权
两个经常被混淆的概念,但它们完全不同
| 认证 Authentication | 授权 Authorization | |
|---|---|---|
| 回答的问题 | 你是谁? | 你能做什么? |
| 常见方式 | 密码登录、OAuth | 角色、权限检查 |
| 比喻 | 检查身份证 | 查看门禁权限 |
| HTTP 状态码 | 401 Unauthorized | 403 Forbidden |
Part 1: 密码哈希
用户密码的安全存储
明文存储密码等于把钥匙放在门口的垫子下面,任何能访问数据库的人都能看到所有用户的密码。密码哈希是用户认证的第一道防线,它让即使数据库泄露,攻击者也无法还原出原始密码。bcrypt 是目前 Node.js 生态中最推荐的密码哈希算法,内置盐值和自适应工作因子,能有效抵抗暴力破解和彩虹表攻击。
为什么用 bcrypt
不是所有哈希算法都适合存密码
| 特性 | bcrypt | MD5/SHA-256 |
|---|---|---|
| 内置盐值 | 是 | 否(需手动加盐) |
| 自适应工作因子 | 是 | 否 |
| 计算慢(抗暴力破解) | 是 | 否(GPU加速极易) |
bcrypt 的"慢"是优点。它让攻击者破解一个密码的成本变得极高。工作因子越高,计算越慢,安全性越强。
bcrypt 用法
两个函数:hash 用于注册,compare 用于登录
$ npm install bcryptjs
import bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12; // 推荐值 10-12
// 注册时:哈希密码
async function hashPassword(plainPassword: string): Promise<string> {
const hash = await bcrypt.hash(plainPassword, SALT_ROUNDS);
return hash;
// 输出类似: $2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
}
// 登录时:验证密码
async function verifyPassword(
plainPassword: string,
hashedPassword: string
): Promise<boolean> {
return bcrypt.compare(plainPassword, hashedPassword);
}
注册/登录服务
将密码哈希集成到用户注册和登录流程中
// src/services/auth.service.ts
import bcrypt from 'bcryptjs';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const SALT_ROUNDS = 12;
export class AuthService {
async register(email: string, password: string, name: string) {
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) throw new Error('该邮箱已被注册');
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
return prisma.user.create({
data: { email, name, password: hashedPassword },
select: { id: true, email: true, name: true, createdAt: true },
});
}
async login(email: string, password: string) {
const user = await prisma.user.findUnique({ where: { email } });
if (!user) throw new Error('邮箱或密码错误'); // 不提示"用户不存在"
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) throw new Error('邮箱或密码错误');
const { password: _, ...safeUser } = user;
return safeUser;
}
}
认证架构
注册、登录、Token 管理的完整数据流

图 A:JWT 认证数据流主图
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:展示 JWT 认证系统的三条核心数据流,包含注册、登录、Token 刷新流程 - 布局分为三个水平泳道,从上到下: - 泳道 1(蓝色,标注"浏览器/客户端"):包含 React 前端应用图标、登录表单示意、Cookie 存储区域、内存存储区域 - 泳道 2(绿色,标注"Express 后端"):包含路由处理器(注册/登录/刷新)、bcrypt 验证模块、JWT 签发模块 - 泳道 3(紫色,标注"数据层"):包含 PostgreSQL 用户表(字段:id, email, password_hash, refresh_token)和 Redis 黑名单(标注"Token 吊销列表") - 三条核心数据流(用不同颜色的箭头): - 注册流程(绿色箭头):前端表单 POST /api/register → 后端 bcrypt.hash → 写入 PostgreSQL → 返回 201 - 登录流程(蓝色箭头):前端 POST /api/login → 后端 bcrypt.compare → 签发 Access Token(15min)+ Refresh Token(7d)→ Access Token 返回 JSON body,Refresh Token 写入 HttpOnly Cookie → 前端保存 Access Token 到内存/state - Token 刷新流程(橙色箭头):前端 Access Token 过期 → 自动 POST /api/refresh(携带 HttpOnly Cookie)→ 后端验证 Refresh Token → 签发新 Access Token + 新 Refresh Token → 旧 Refresh Token 写入 Redis 黑名单 - 在左下角画一个安全注解区域: - 标注"Access Token:短命(15min),存 JS 内存,不存 localStorage(防 XSS)" - 标注"Refresh Token:长寿(7d),存 HttpOnly + Secure + SameSite Cookie(防 CSRF)" - 配色建议:浅灰背景,三个泳道分别用浅蓝、浅绿、浅紫底色,注册流用绿色箭头,登录流用蓝色箭头,刷新流用橙色箭头,安全注解用浅黄底色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、箭头交叉混乱、文字过小 - 标注文字用中文,API 路径和代码用等宽字体

图 B:JWT 验证中间件流程
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:展示 JWT 验证中间件(requireAuth)的完整工作流程,从请求进入到通过或拒绝的分支判断 - 流程步骤(从左到右的流程图,用矩形和菱形判断节点): 1. 起始节点(圆角矩形):"收到请求" 2. 矩形节点:"从 Authorization 头提取 Bearer Token" 3. 菱形判断节点:"Token 存在?" - 否 → 红色箭头 → 终止节点"返回 401:未提供认证令牌" - 是 → 继续下一步 4. 矩形节点:"JWT.verify() 验证签名和有效期" 5. 菱形判断节点:"验证通过?" - 否 → 菱形判断节点"错误类型?" - TokenExpiredError → 红色箭头 → "返回 401:令牌已过期,请刷新" - 其他错误 → 红色箭头 → "返回 403:无效的认证令牌" - 是 → 继续下一步 6. 菱形判断节点(可选):"检查 Redis 黑名单?" - 在黑名单中 → 红色箭头 → "返回 401:令牌已被撤销" - 不在黑名单 → 继续下一步 7. 矩形节点:"将解码后的用户信息挂载到 req.user" 8. 绿色终止节点:"调用 next() → 请求进入路由处理器" - 右侧画一个代码片段注释框,展示 req.user 的类型结构:`{ userId: string, email: string, role: string }` - 配色建议:浅灰背景,正常流程蓝色系箭头,拒绝路径红色系箭头,判断节点橙色菱形,成功节点绿色,失败节点红色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、箭头交叉混乱 - 标注文字用中文,代码用等宽字体
Part 2: JWT 认证
无状态令牌认证方案
用户登录后,服务器需要一个方式来"记住"这个用户。JWT(JSON Web Token)是目前最流行的无状态认证方案,它把用户信息编码成一个带签名的令牌字符串,服务器不需要存储会话数据就能验证用户身份。我们将学习双令牌策略——Access Token 负责短期访问、Refresh Token 负责长期续期,兼顾安全性和用户体验。
JWT 结构
三段 Base64 编码的字符串,用点号分隔
Header.Payload.Signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjF9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
| 部分 | 内容 | 说明 |
|---|---|---|
| Header | 算法和类型 | {"alg":"HS256","typ":"JWT"} |
| Payload | 用户数据 | userId、email、role 等 |
| Signature | 签名 | 防止数据被篡改 |
双令牌策略
Access Token 短命,Refresh Token 长命
| 属性 | Access Token | Refresh Token |
|---|---|---|
| 有效期 | 短(15-30分钟) | 长(7-30天) |
| 用途 | 访问受保护的 API | 获取新的 Access Token |
| 存储位置 | 内存 / httpOnly Cookie | httpOnly Cookie / 数据库 |
| 泄露风险 | 短有效期降低风险 | 可被撤销 |
认证流程
从登录到访问受保护资源的完整流程

图:JWT 登录认证与 Token 刷新的完整时序流程
【AI 生图提示词】请生成一张中文技术时序图,风格简洁、配色柔和、适合教学: - 画面主体:客户端与服务器之间的 JWT 认证完整时序交互,包含登录、访问受保护资源、Token 刷新三个阶段 - 三个参与者(从左到右排列的竖线):客户端(蓝色)、服务器(绿色)、数据库(紫色) - 阶段一(登录流程,蓝色区域标注): - 客户端 → 服务器:实线箭头标注 "POST /login {email, password}" - 服务器 → 数据库:实线箭头标注 "查找用户 + 验证密码" - 数据库 → 服务器:虚线箭头标注 "验证通过" - 服务器 → 客户端:虚线箭头标注 "返回 Access Token + Refresh Token(Cookie)" - 阶段二(访问受保护资源,绿色区域标注): - 客户端 → 服务器:实线箭头标注 "GET /api/me",旁边小字标注 "带 Authorization: Bearer token" - 服务器 → 服务器:自环箭头标注 "验证 JWT 签名和有效期" - 服务器 → 客户端:虚线箭头标注 "200 返回用户数据" - 阶段三(Token 刷新,橙色区域标注): - 在客户端和服务器之间画一条水平注释线,标注 "Access Token 过期后" - 客户端 → 服务器:实线箭头标注 "POST /refresh",旁边小字标注 "Cookie 自带 Refresh Token" - 服务器 → 服务器:自环箭头标注 "验证 Refresh Token" - 服务器 → 客户端:虚线箭头标注 "返回新的 Access Token" - 三个阶段用水平分隔线或浅色背景区分,每个阶段左侧加文字标签 - 配色建议:浅灰背景,客户端蓝色竖线,服务器绿色竖线,数据库紫色竖线,登录流蓝色箭头,访问流绿色箭头,刷新流橙色箭头 - 画面比例:16:9 - 避免:过度装饰、3D 效果、箭头交叉混乱
JWT 工具函数
生成、验证令牌的封装
$ npm install jsonwebtoken
$ npm install -D @types/jsonwebtoken
// src/utils/jwt.ts
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'access-secret-dev';
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'refresh-secret-dev';
interface TokenPayload {
userId: string;
email: string;
role: string;
}
export function generateAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, ACCESS_SECRET, { expiresIn: '15m' });
}
export function generateRefreshToken(payload: TokenPayload): string {
return jwt.sign(payload, REFRESH_SECRET, { expiresIn: '7d' });
}
export function verifyAccessToken(token: string): TokenPayload {
return jwt.verify(token, ACCESS_SECRET) as TokenPayload;
}
export function generateTokenPair(payload: TokenPayload) {
return {
accessToken: generateAccessToken(payload),
refreshToken: generateRefreshToken(payload),
};
}
登录接口
验证通过后签发令牌对
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await authService.login(email, password);
const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role });
// Refresh Token 放 httpOnly Cookie
res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/auth/refresh',
});
res.json({ user, accessToken: tokens.accessToken });
});
Token 刷新接口
Access Token 过期后,用 Refresh Token 无感续期
Access Token 有效期只有 15 分钟,过期后用户不应该被强制重新登录。POST /auth/refresh 接口从 httpOnly Cookie 中取出 Refresh Token,验证通过后签发新的令牌对。为了安全,每次刷新都会轮换(Rotate)Refresh Token——旧 Token 立即失效,新 Token 写入 Cookie。
router.post('/refresh', async (req, res) => {
const token = req.cookies.refreshToken;
if (!token) {
res.status(401).json({ error: '缺少 Refresh Token' });
return;
}
try {
// 1. 验证 Refresh Token
const decoded = jwt.verify(token, REFRESH_SECRET) as TokenPayload;
// 2. 检查是否已被撤销(可选:查 Redis 黑名单)
const blacklisted = await redis.get(`bl:${token}`);
if (blacklisted) {
res.status(401).json({ error: '令牌已被撤销' });
return;
}
// 3. 将旧 Refresh Token 加入黑名单
const ttl = decoded.exp! - Math.floor(Date.now() / 1000);
await redis.set(`bl:${token}`, '1', 'EX', ttl);
// 4. 签发新令牌对(轮换)
const payload = { userId: decoded.userId, email: decoded.email, role: decoded.role };
const newTokens = generateTokenPair(payload);
// 5. 写入新 Cookie
res.cookie('refreshToken', newTokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/auth/refresh',
});
res.json({ accessToken: newTokens.accessToken });
} catch {
res.status(401).json({ error: 'Refresh Token 无效或已过期' });
}
});
登出与令牌撤销
用户点了"退出登录",Token 怎么失效?
JWT 是无状态的,一旦签发就无法"取消"。那用户登出怎么办?常见方案有两种:客户端清除(删除 Cookie 和内存中的 Token)和服务端黑名单(用 Redis 记录已撤销的 Token)。两者结合才能实现真正的登出。
router.post('/logout', requireAuth, async (req, res) => {
const refreshToken = req.cookies.refreshToken;
// 1. 将 Refresh Token 加入 Redis 黑名单
if (refreshToken) {
try {
const decoded = jwt.decode(refreshToken) as any;
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) await redis.set(`bl:${refreshToken}`, '1', 'EX', ttl);
} catch { /* Token 可能已无效,忽略 */ }
}
// 2. 清除 Cookie
res.clearCookie('refreshToken', { path: '/api/auth/refresh' });
// 3. 前端也需清除内存中的 Access Token
res.json({ message: '已成功登出' });
});
为什么 JWT 不能被真正"撤销"? 因为 JWT 的验证只依赖密钥,不需要查数据库。如果要求每次请求都查库验证,那就失去了 JWT 无状态的优势——这时不如直接用 Session 方案。黑名单是一种折中:只在登出/撤销时写 Redis,不影响正常请求的性能。
Part 3: 认证中间件
在路由中统一校验用户身份
JWT 签发了,但怎么在路由中检查?答案是中间件。Express 中间件可以在请求到达控制器之前自动提取并验证 Token,将解码后的用户信息挂载到 req.user 上,后续的路由处理器就能直接使用。这样每个需要认证的接口只需加一行中间件调用,无需重复编写验证逻辑。
requireAuth 中间件
从请求头提取 Token,验证后把用户信息挂到 req 上
// src/middleware/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
declare global {
namespace Express {
interface Request {
user?: TokenPayload;
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: '未提供认证令牌' });
return;
}
const token = authHeader.split(' ')[1];
try {
req.user = verifyAccessToken(token);
next();
} catch (error: any) {
if (error.name === 'TokenExpiredError') {
res.status(401).json({ error: '令牌已过期,请刷新' });
return;
}
res.status(403).json({ error: '无效的认证令牌' });
}
}
在路由中使用
一行代码保护任何路由
// 公开路由
router.get('/public-profile/:id', userController.getPublicProfile);
// 需要认证
router.get('/me', requireAuth, userController.getMe);
router.get('/me/posts', requireAuth, userController.getMyPosts);
router.patch('/me', requireAuth, userController.updateMe);
// 控制器中使用 req.user
export function getMe(req: Request, res: Response) {
const user = req.user!; // 经过 requireAuth 后一定存在
res.json({ id: user.userId, email: user.email, role: user.role });
}
密码重置流程
"忘记密码"功能的完整实现
密码重置分两步:先通过 POST /auth/forgot-password 生成一个短期有效的重置令牌并发送到用户邮箱;再通过 POST /auth/reset-password 验证令牌后更新密码。重置令牌应该是一次性的,用过即失效。

图:密码重置流程的完整时序交互
【AI 生图提示词】请生成一张中文技术时序图,风格简洁、配色柔和、适合教学: - 画面主体:用户通过邮箱重置密码的完整时序交互,分为"请求重置"和"执行重置"两个阶段 - 五个参与者(从左到右排列的竖线):用户(黄色)、前端(蓝色)、后端(绿色)、邮件服务(橙色)、数据库(紫色) - 阶段一(请求重置密码,蓝色背景区域标注"第一步:请求重置"): - 用户 → 前端:实线箭头标注 "点击'忘记密码',输入邮箱" - 前端 → 后端:实线箭头标注 "POST /auth/forgot-password" - 后端 → 数据库:实线箭头标注 "查找用户" - 后端 → 后端:自环箭头标注 "生成随机 Token(crypto.randomBytes)" - 后端 → 数据库:实线箭头标注 "存储 Token 哈希 + 过期时间" - 后端 → 邮件服务:实线箭头标注 "发送重置邮件(含明文 Token)" - 邮件服务 → 用户:虚线箭头标注 "用户收到邮件" - 阶段二(执行重置密码,绿色背景区域标注"第二步:执行重置"): - 用户 → 前端:实线箭头标注 "点击邮件中的链接" - 前端 → 后端:实线箭头标注 "POST /auth/reset-password {token, newPassword}" - 后端 → 数据库:实线箭头标注 "查找匹配的 Token 哈希 + 未过期" - 后端 → 数据库:实线箭头标注 "更新密码哈希,清除 Token" - 后端 → 前端:虚线箭头标注 "密码重置成功" - 配色建议:浅灰背景,用户黄色竖线,前端蓝色竖线,后端绿色竖线,邮件服务橙色竖线,数据库紫色竖线,请求箭头深色实线,响应/通知箭头虚线 - 画面比例:16:9 - 避免:过度装饰、3D 效果、箭头交叉混乱
密码重置代码
用 crypto 生成令牌,用 Prisma 存储验证
import crypto from 'crypto';
import { sendEmail } from '../utils/email';
// 第一步:请求重置密码
router.post('/forgot-password', async (req, res) => {
const { email } = req.body;
const user = await prisma.user.findUnique({ where: { email } });
// 即使用户不存在也返回成功,防止邮箱枚举
if (!user) return res.json({ message: '如果该邮箱已注册,你将收到重置邮件' });
const resetToken = crypto.randomBytes(32).toString('hex');
const hashedToken = crypto.createHash('sha256').update(resetToken).digest('hex');
await prisma.user.update({
where: { id: user.id },
data: {
resetToken: hashedToken,
resetTokenExpiry: new Date(Date.now() + 15 * 60 * 1000),
},
});
const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`;
await sendEmail(user.email, '密码重置', `点击链接重置密码:${resetUrl}`);
res.json({ message: '如果该邮箱已注册,你将收到重置邮件' });
});
// 第二步:重置密码
router.post('/reset-password', async (req, res) => {
const { token, newPassword } = req.body;
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
const user = await prisma.user.findFirst({
where: {
resetToken: hashedToken,
resetTokenExpiry: { gt: new Date() },
},
});
if (!user) { res.status(400).json({ error: '令牌无效或已过期' }); return; }
await prisma.user.update({
where: { id: user.id },
data: {
password: await bcrypt.hash(newPassword, 12),
resetToken: null,
resetTokenExpiry: null,
},
});
res.json({ message: '密码重置成功' });
});
☕ 学习断点
认证基础已完成,休息后进入授权部分
你已经学会了密码哈希、JWT 认证和认证中间件——这是用户系统的核心安全机制。
建议:休息 5 分钟,回顾一下 JWT 的签发和验证流程。
💡 后半部分将学习 RBAC 角色权限控制和 OAuth 第三方登录——这些是高级认证授权的常见模式。
Part 4: RBAC
基于角色的访问控制
不是所有人都能做所有事。RBAC(Role-Based Access Control)通过给用户分配角色、给角色分配权限,实现了灵活的访问控制。比如普通用户只能查看和编辑自己的内容,编辑可以管理所有文章,管理员拥有全部权限。这种模式在企业应用中几乎是标配。
权限矩阵
哪个角色能做什么,一目了然
| 操作 | ADMIN | EDITOR | USER |
|---|---|---|---|
| 查看所有用户 | 是 | 否 | 否 |
| 删除任何文章 | 是 | 是 | 否 |
| 创建文章 | 是 | 是 | 是 |
| 编辑自己的文章 | 是 | 是 | 是 |
| 管理用户角色 | 是 | 否 | 否 |
角色检查
工厂函数生成角色检查中间件
type Role = 'ADMIN' | 'EDITOR' | 'USER';
const ROLE_HIERARCHY: Record<Role, number> = {
USER: 1, EDITOR: 2, ADMIN: 3,
};
export function requireRole(...allowedRoles: Role[]) {
return (req: Request, res: Response, next: NextFunction) => {
requireAuth(req, res, () => {
const userRole = req.user!.role as Role;
if (!allowedRoles.includes(userRole)) {
res.status(403).json({ error: '权限不足' });
return;
}
next();
});
};
}
// 使用
router.get('/admin/users', requireRole('ADMIN'), adminController.listUsers);
router.delete('/posts/:id', requireRole('ADMIN', 'EDITOR'), postController.deletePost);
Part 5: OAuth
第三方登录集成
"用 GitHub 登录"——OAuth 2.0 让用户不用在你的网站上注册新账号就能登录,极大降低了注册门槛。它的工作原理是把认证过程委托给可信的第三方(GitHub、Google、微信等),你的后端只需要拿到授权码换取用户信息,然后签发自己的 JWT。Passport.js 是 Node.js 生态中最成熟的 OAuth 实现库。
OAuth 流程
用户点击"GitHub 登录"后发生了什么

图:OAuth 2.0 第三方 GitHub 登录的完整时序流程
【AI 生图提示词】请生成一张中文技术时序图,风格简洁、配色柔和、适合教学: - 画面主体:用户通过 GitHub OAuth 2.0 实现第三方登录的完整时序交互图 - 四个参与者(从左到右排列的竖线):用户(黄色)、前端(蓝色)、你的后端(绿色)、GitHub(黑色/深灰色,带 GitHub logo 风格) - 完整流程(从上到下): - 用户 → 前端:实线箭头标注 "点击'GitHub 登录'" - 前端 → GitHub:实线粗箭头标注 "重定向到 GitHub 授权页"(用特殊样式表示浏览器跳转) - 用户 → GitHub:实线箭头标注 "确认授权" - GitHub → 后端:实线箭头标注 "回调,附带授权码 code" - 后端 → GitHub:实线箭头标注 "用 code 换 Access Token" - GitHub → 后端:虚线箭头标注 "返回 Access Token" - 后端 → GitHub:实线箭头标注 "用 Token 获取用户信息" - GitHub → 后端:虚线箭头标注 "返回用户信息" - 后端 → 后端:自环箭头标注 "查找或创建用户,签发 JWT" - 后端 → 前端:虚线箭头标注 "返回 JWT Token" - 关键标注:授权码 code 用橙色高亮,Access Token 用蓝色高亮,JWT 用绿色高亮,清晰区分三种令牌的不同用途 - 在右侧添加简短的文字注释框:"授权码模式(Authorization Code Flow)" - 配色建议:浅灰背景,用户黄色竖线,前端蓝色竖线,后端绿色竖线,GitHub 深灰竖线,浏览器跳转用特殊粗箭头,普通请求实线,响应用虚线 - 画面比例:16:9 - 避免:过度装饰、3D 效果、箭头交叉混乱
Passport.js 实现
Node.js 生态中最成熟的 OAuth 库
$ npm install passport passport-github2 express-session
$ npm install -D @types/passport @types/passport-github2
import passport from 'passport';
import { Strategy as GitHubStrategy } from 'passport-github2';
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
callbackURL: process.env.GITHUB_CALLBACK_URL!,
}, async (accessToken, refreshToken, profile, done) => {
// 查找或创建用户
let user = await prisma.user.findFirst({
where: { oauthProvider: 'github', oauthId: profile.id },
});
if (!user) {
user = await prisma.user.create({
data: {
email: profile.emails?.[0]?.value,
name: profile.displayName,
password: '', // OAuth 用户没有密码
},
});
}
done(null, user);
}));
Session 方案
JWT 不是唯一选择——Session 方案同样经典
Session 方案将用户状态存储在服务器端(通常是 Redis),客户端只持有一个 Session ID Cookie。相比 JWT,Session 可以被服务器立即撤销,但需要额外的存储依赖。下面是两种方案的对比:
| 对比项 | JWT | Session |
|---|---|---|
| 存储位置 | 客户端(无状态) | 服务器端(Redis/DB) |
| 可撤销性 | 需要黑名单 | 直接删除 session |
| 水平扩展 | 天然支持 | 需要共享存储 |
| 适合移动端 | 是 | 需要额外处理 |
| 适用场景 | 移动端、微服务、SSO | 传统 Web 应用、管理后台 |
何时选 Session?如果你的应用只需要浏览器访问、需要即时撤销能力、且不想处理 Token 黑名单的复杂性,Session + Redis 是更简单、更直观的选择。
Session 代码实现
express-session + connect-redis 配置
$ npm install express-session connect-redis ioredis
import session from 'express-session';
import RedisStore from 'connect-redis';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
app.use(session({
store: new RedisStore({ client: redis, prefix: 'sess:' }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000, // 1 天
},
}));
// 登录时存储用户信息
router.post('/login', async (req, res) => {
const user = await authService.login(req.body.email, req.body.password);
req.session.userId = user.id;
req.session.role = user.role;
res.json({ user });
});
// 登出时销毁 session
router.post('/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('connect.sid');
res.json({ message: '已登出' });
});
});
// 受保护路由直接读 session
router.get('/me', (req, res) => {
if (!req.session.userId) {
res.status(401).json({ error: '请先登录' });
return;
}
// ...
});
CSRF 防护
为什么把 JWT 放在 Cookie 里仍然有风险
CSRF(跨站请求伪造)攻击利用浏览器自动携带 Cookie 的特性:用户在银行网站已登录,然后访问了一个恶意页面,恶意页面的 JavaScript 可以向银行发起转账请求——浏览器会自动带上银行的 Cookie。
| 防护方案 | 原理 | 优缺点 |
|---|---|---|
| SameSite Cookie | Cookie 只在同站请求中发送 | 最简单,但旧浏览器不支持 |
| CSRF Token | 服务器签发 Token,表单提交时校验 | 最经典,需前后端配合 |
| Double-submit Cookie | Cookie 和 Header 中都放 Token,比对是否一致 | 无需服务器存储 |
| Bearer Token(非 Cookie) | Token 放 Authorization 头,浏览器不会自动发送 | 天然免疫 CSRF |
CSRF 防护代码
SameSite + Double-submit Cookie
// 方案一:SameSite Cookie(最简单,现代浏览器都支持)
res.cookie('token', token, {
httpOnly: true,
sameSite: 'strict', // 或 'lax'(允许安全的顶级导航)
secure: true,
});
// 方案二:Double-submit Cookie(无需服务端存储,自己实现约 20 行)
import crypto from 'crypto';
// 签发:把一个随机 token 同时写入 Cookie 和响应体
router.get('/csrf-token', (req, res) => {
const csrfToken = crypto.randomBytes(32).toString('hex');
res.cookie('csrfToken', csrfToken, {
httpOnly: false, // 前端 JS 要读取后放进请求头
sameSite: 'strict',
secure: true,
});
res.json({ csrfToken });
});
// 校验中间件:比对 Cookie 里的 token 与自定义请求头里的 token 是否一致
function csrfProtection(req, res, next) {
const cookieToken = req.cookies.csrfToken;
const headerToken = req.headers['x-csrf-token'];
// 长度相等且恒等比较,避免时序攻击
if (!cookieToken || !headerToken ||
cookieToken.length !== headerToken.length ||
!crypto.timingSafeEqual(Buffer.from(cookieToken), Buffer.from(headerToken))) {
return res.status(403).json({ error: 'CSRF 校验失败' });
}
next();
}
// 需要 CSRF 保护的请求
router.post('/transfer', csrfProtection, (req, res) => {
res.json({ message: '转账成功' });
});
csurf 这个包。曾经最流行的 Express CSRF 中间件 csurf 自 2022 年起已停止维护,且其自身存在已知 CSRF 绕过漏洞,官方已明确建议弃用。上面给出的 Double-submit Cookie 自实现只有约 20 行、无第三方依赖,足够大多数场景;如果需要更完善的方案(如自动轮换 token、可配置 cookie 选项),可使用维护中的 csrf-csrf 替代。
输入验证 Schema
用 Zod 为认证接口定义严格的验证规则
认证系统是攻击的首要目标。注册邮箱需要格式验证和归一化、密码需要强度检查、登录参数需要长度限制。Zod 是 TypeScript 生态中最适合做请求验证的库——它既能验证运行时数据,又能自动推导类型。
$ npm install zod
import { z } from 'zod';
// 注册验证:邮箱归一化 + 密码强度
const registerSchema = z.object({
email: z.string().email('邮箱格式不正确').toLowerCase().trim(),
password: z.string()
.min(8, '密码至少 8 位')
.max(128, '密码不超过 128 位')
.regex(/[A-Z]/, '需要至少一个大写字母')
.regex(/[a-z]/, '需要至少一个小写字母')
.regex(/[0-9]/, '需要至少一个数字')
.regex(/[^A-Za-z0-9]/, '需要至少一个特殊字符'),
name: z.string().min(2).max(50).trim(),
});
// 登录验证:邮箱归一化 + 密码非空
const loginSchema = z.object({
email: z.string().email('邮箱格式不正确').toLowerCase().trim(),
password: z.string().min(1, '请输入密码'),
});
// 密码重置验证
const resetPasswordSchema = z.object({
token: z.string().length(64, '令牌格式无效'), // 32 bytes hex
newPassword: z.string().min(8).max(128),
});
验证中间件集成
一行代码为路由加上输入验证
// 统一验证中间件
function validate(schema: z.ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
error: '输入验证失败',
details: result.error.flatten().fieldErrors,
});
return;
}
req.body = result.data; // 使用验证+归一化后的数据
next();
};
}
// 路由中使用
router.post('/register', validate(registerSchema), authController.register);
router.post('/login', validate(loginSchema), authController.login);
router.post('/reset-password', validate(resetPasswordSchema), authController.resetPassword);
// 错误响应示例
// POST /register { email: "abc", password: "123" }
// 400 {
// "error": "输入验证失败",
// "details": {
// "email": ["邮箱格式不正确"],
// "password": ["密码至少 8 位", "需要至少一个大写字母", ...]
// }
// }
result.data 替换 req.body。 这样后续代码拿到的是经过归一化(如 toLowerCase()、trim())后的干净数据。
常见问题
问题一:Token 验证总是返回"无效"
原因:签发和验证使用的密钥不一致,或环境变量在不同环境下值不同。
修复:确保 JWT_ACCESS_SECRET 在签发和验证时是同一个值。检查 .env 文件。
问题二:bcrypt.compare 总返回 false
原因:注册时哈希的密码可能被截断了(数据库字段太短)。
修复:bcrypt 哈希结果固定 60 字符,确保数据库 password 字段至少 VARCHAR(255)。
问题三:OAuth 回调报错"redirect_uri_mismatch"
原因:GitHub 后台配置的回调 URL 和代码中的不一致。
修复:在 GitHub Settings > Developer settings 中检查回调 URL,确保和 GITHUB_CALLBACK_URL 完全一致。
问题四:Refresh Token 刷新后旧 Token 仍可用
原因:Redis 黑名单未生效,或 TTL 设置为 0 导致 key 立即过期。
修复:确认 ttl = decoded.exp - now 为正数再写入 Redis,并用 redis.get 验证黑名单确实生效。
试一试
练习一
任务:写一个注册接口,接收 email 和 password。用 bcrypt 哈希密码后存入数据库。
// 提示:先查重,再 hash,再 create
// 你的代码:
参考答案
router.post('/register', asyncHandler(async (req, res) => {
const { email, password } = req.body;
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) throw new AppError(409, '该邮箱已注册');
const hash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { email, password: hash, name: email.split('@')[0] },
select: { id: true, email: true },
});
res.status(201).json(user);
}));
练习二
任务:给 DELETE /api/posts/:id 加上认证和权限检查——只有 ADMIN 或文章作者才能删除。
// 提示:requireAuth + 检查 req.user.role 或 post.authorId
// 你的代码:
参考答案
router.delete('/posts/:id', requireAuth, asyncHandler(async (req, res) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) throw new AppError(404, '文章不存在');
if (req.user!.role !== 'ADMIN' && post.authorId !== req.user!.userId) {
throw new AppError(403, '只能删除自己的文章');
}
await prisma.post.delete({ where: { id: req.params.id } });
res.json({ message: '删除成功' });
}));
练习三:防枚举登录
任务:下面这个登录接口有信息泄露漏洞——攻击者能通过不同的错误信息判断哪些邮箱已注册。修复它。
// 漏洞代码:错误信息暴露了用户是否存在
const user = await prisma.user.findUnique({ where: { email } });
if (!user) throw new AppError(404, '用户不存在'); // ← 暴露了
const match = await bcrypt.compare(password, user.password);
if (!match) throw new AppError(401, '密码错误'); // ← 也暴露了
要求:无论邮箱是否存在、密码是否正确,都返回相同的错误信息和状态码。但为了不泄露时序信息(通过响应时间判断),compare 仍要执行。
参考答案
router.post('/login', asyncHandler(async (req, res) => {
const { email, password } = req.body;
const user = await prisma.user.findUnique({ where: { email } });
// 统一错误:不区分"用户不存在"和"密码错误"
const GENERIC = '邮箱或密码错误';
// 即便用户不存在,也做一次 compare,保证响应时间一致
const hash = user?.password ?? '$2b$12$无效占位哈希值..............';
const match = await bcrypt.compare(password, hash);
if (!user || !match) throw new AppError(401, GENERIC);
// ...签发 token
}));
为什么这么做:枚举攻击(enumeration)是黑客收集有效账号的常用手段。"该邮箱未注册"这句话本身就是在帮攻击者。
练习四:Refresh Token 轮换
任务:实现 refresh 接口的核心逻辑——验证旧 refresh token、签发新的一对 token,并让旧 refresh token 立即失效(防止重放)。
// 假设 Redis 可用,key 格式:refresh:{userId}
// 提示:旧 token 用过即弃,存入"黑名单"或直接覆盖
async function refresh(oldRefreshToken) {
// 1. 验证旧 token 的签名和有效期
// 2. 检查它是否仍有效(未被吊销)
// 3. 签发新的 access + refresh token
// 4. 让旧 refresh token 失效
// 你的代码:
}
关键点:如果不做"用过即弃",一个泄露的 refresh token 可以无限续期——这是 JWT 方案最危险的地方。
参考答案
async function refresh(oldRefreshToken) {
// 1. 验证签名 + 有效期
const payload = jwt.verify(oldRefreshToken, REFRESH_SECRET);
// 2. 检查是否仍是当前有效的 token(防重放)
const stored = await redis.get(`refresh:${payload.userId}`);
if (stored !== oldRefreshToken) {
// 旧 token 已被轮换 → 可能被盗用,吊销整个会话
await redis.del(`refresh:${payload.userId}`);
throw new AppError(401, '请重新登录');
}
// 3. 签发新的一对 token
const newAccess = signAccessToken(payload.userId);
const newRefresh = signRefreshToken(payload.userId);
// 4. 旧 token 立即失效:用新 token 覆盖存储
await redis.set(`refresh:${payload.userId}`, newRefresh, 'EX', 60 * 60 * 24 * 7);
return { accessToken: newAccess, refreshToken: newRefresh };
}
进阶思考:如果用户在两台设备上登录,上面这种"单 token 覆盖"会让一台设备的登录挤掉另一台。生产系统通常为每个设备维护独立的 refresh token,或采用"令牌家族"(token family)检测重放。
📝 要点总结
本章核心知识点
密码安全
bcrypt 哈希 + 盐值,即使数据库泄露也无法还原原始密码
JWT 认证
Access Token + Refresh Token 双令牌策略,平衡安全性和用户体验
认证中间件
requireAuth 中间件统一校验 Token,保护受限路由
RBAC 授权
角色 + 权限矩阵,控制不同用户对资源的访问级别
下一章
认证搞定了。下一章学习文件上传与静态资源——让用户能上传头像、图片和文档。