认证
这一课你将能做到
- 讲清楚 Session 与 JWT 两种方案的完整流程、存储位置和各自适用场景
- 用 bcrypt + JWT 实现注册/登录/鉴权中间件的完整代码并跑通
- 说出密码为什么必须用 bcrypt 而不是 MD5,以及 OAuth2 授权码流程在各家「第三方登录」里是怎么发生的
为什么要学
HTTP 是无状态的(第 1 课说过):服务器处理完上一个请求就忘了你是谁。但「改我的笔记、看我的订单」必须知道你是谁、是不是你本人——认证(Authentication)解决「你是谁」,授权(Authorization)解决「你能干什么」,两者合起来是所有业务接口的地基,也是面试的高频题。
人话:认证就像进写字楼的两种方式。Session 是前台发你一张临时工牌,每次进门前台查登记本确认(状态存服务器);JWT 是发你一枚加密徽章,徽章本身写着你的部门和有效期,保安用机器验个章就放行(状态存客户端),不用查本,人再多也快。
先建立图像
两种方案的完整流程对比:
【Session 方案】 【JWT 方案】
登录: 账号密码 ──▶ 服务器存 Session 登录: 账号密码 ──▶ 服务器签发 JWT
◀── SessionID(放进 Cookie) ◀── token(客户端自己存)
后续: 每个请求自动带 Cookie 后续: 每个请求手动带
Header: Cookie: sid=xxx Header: Authorization: Bearer xxx
服务器 ──▶ 拿 sid 查 Session 存储 服务器 ──▶ 用密钥本地验签,不查库
(多台服务器要共享 Session 存储) (无状态,天然支持水平扩容)
JWT 长这样(三段用点隔开,前两段只是 Base64URL 编码,不是加密):
eyJhbGciOiJIUzI1NiJ9 . eyJ1c2VybmFtZSI6ImFsaWNlIiwiaWF0IjoxNzM1... . sflKxwRJSMeKKF2QT4f...
头部(header) 载荷(payload) 签名(signature)
声明签名算法 HS256 存用户ID、过期时间等 对前两段的防篡改签名
跟着做
步骤 1:先搞懂密码为什么不能明文存
mkdir bebg-auth && cd bebg-auth && npm init -y && npm install express bcryptjs jsonwebtoken
先做一个小实验,理解哈希与加盐:
// hash-demo.mjs 运行:node hash-demo.mjs(package.json 无 "type":"module" 时请命名为 hash-demo.cjs 并改用 require)
const bcrypt = require('bcryptjs');
(async () => {
const password = 'my-secret-123';
const hash1 = await bcrypt.hash(password, 10); // 10 是 cost 因子,2^10 轮加盐哈希
const hash2 = await bcrypt.hash(password, 10);
console.log('同一密码两次哈希:', hash1 !== hash2); // true —— 盐是随机的
console.log('哈希样例:', hash1); // $2a$10$N9qo8uLOickgx2ZMRZoMye...
console.log('校验正确密码:', await bcrypt.compare(password, hash1)); // true
console.log('校验错误密码:', await bcrypt.compare('wrong', hash1)); // false
})();
三个关键观察:同一密码每次哈希结果不同(自动随机加盐,彩虹表失效);校验不是重新哈希对比,而是 bcrypt.compare;cost 因子让暴力破解成本可控地变贵。MD5/SHA256 没有内置盐且计算太快,专攻密码存储的 bcrypt/scrypt/argon2 才是正解。
步骤 2:注册 + 登录接口
// server.js
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const app = express();
app.use(express.json());
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
const users = []; // 演示用内存存储,真实项目存数据库
// 注册
app.post('/api/auth/register', async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password || password.length < 8) {
return res.status(400).json({ error: '用户名必填,密码至少 8 位' });
}
if (users.some(u => u.username === username)) {
return res.status(409).json({ error: '用户名已存在' });
}
const passwordHash = await bcrypt.hash(password, 10); // 只存哈希,永不存明文
users.push({ username, passwordHash });
res.status(201).json({ message: '注册成功' });
});
// 登录:校验密码,签发 JWT
app.post('/api/auth/login', async (req, res) => {
const { username, password } = req.body || {};
const user = users.find(u => u.username === username);
// 用户不存在和密码错误返回同一句话,不给撞库者提示
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: '用户名或密码错误' });
}
const token = jwt.sign(
{ sub: username, role: 'user' }, // payload:只放非敏感信息
JWT_SECRET,
{ expiresIn: '2h', issuer: 'bebg-demo' }
);
res.json({ token });
});
app.listen(3000, () => console.log('http://localhost:3000'));
步骤 3:鉴权中间件保护接口
// 鉴权中间件:从 Authorization 头取 Bearer token 验签
function auth(req, res, next) {
const header = req.headers.authorization || '';
if (!header.startsWith('Bearer ')) {
return res.status(401).json({ error: '未登录' });
}
try {
const payload = jwt.verify(header.slice(7), JWT_SECRET); // 验签 + 查过期
req.user = payload; // 挂到 req 上,后续处理器直接用
next();
} catch {
res.status(401).json({ error: 'token 无效或已过期' });
}
}
// 受保护接口:谁登录的,就只能看谁的资料
app.get('/api/me', auth, (req, res) => {
res.json({ username: req.user.username, role: req.user.role });
});
步骤 4:curl 走通全流程
# 注册
curl -i -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"password123"}'
# 201
# 登录拿 token
curl -s -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"password123"}'
# {"token":"eyJhbGciOi..."}
# 不带 token 访问受保护接口
curl -i http://localhost:3000/api/me
# 401 {"error":"未登录"}
# 带 token 访问(把 <token> 换成上一步拿到的值)
curl http://localhost:3000/api/me -H "Authorization: Bearer <token>"
# {"username":"alice","role":"user"}
# 把 payload 解开看看:token 第二段是 Base64URL,直接可解码
node -e "console.log(JSON.parse(Buffer.from(process.argv[1].split('.')[1], 'base64url').toString()))" <token>
最后一条命令的输出会让你彻底记住:JWT 的 payload 任何人都能读,只是不能改(一改签名就对不上)。
步骤 5:Session 方案对照(express-session)
// session-demo.js —— npm install express-session
const express = require('express');
const session = require('express-session');
const app = express();
app.use(express.json());
app.use(session({
secret: 'dev-secret-change-me', // 用来给 SessionID 签名
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, maxAge: 2 * 60 * 60 * 1000 }, // httpOnly: JS 读不到,防 XSS 偷
}));
app.post('/api/login', (req, res) => {
const { username } = req.body || {};
if (!username) return res.status(400).json({ error: '用户名必填' });
req.session.username = username; // 登录态存在服务端(默认内存存储,生产要换 Redis 存储)
res.json({ message: 'ok' });
});
app.get('/api/me', (req, res) => {
if (!req.session.username) return res.status(401).json({ error: '未登录' });
res.json({ username: req.session.username });
});
app.listen(3001, () => console.log('http://localhost:3001'));
Session 的登录态由浏览器 Cookie 自动携带(curl 用 -c cookies.txt -b cookies.txt 保存和回放),前端零代码;JWT 则要求前端每次手动加 Header。
步骤 6:OAuth2 是怎么一回事
你在很多网站见过「用 GitHub 登录」,背后是 OAuth2 授权码流程:
你的网站 GitHub 授权服务器 GitHub API
│ ①跳转 GitHub 授权页(带 client_id+回调地址) │ │
│──────────────────────────────▶│ │
│ ②用户登录并同意授权 │ │
│◀───────────③回调你的地址,带一次性 code─────────│ │
│ ④用 code + client_secret 换 access_token │ │
│──────────────────────────────▶│ │
│ ⑤拿 access_token 调 API 读用户资料 │ │
│─────────────────────────────────────────────────────────────────▶│
要点:你的网站全程拿不到用户的 GitHub 密码;client_secret 只能存在服务器端;拿到 access_token 后调 GitHub 的 API 获取用户信息并为自己系统建立会话。严格说 OAuth2 是授权协议,登录场景配合 OIDC(OpenID Connect)才是完整的认证方案——面试能说到这一层就超过大多数人。
讲清楚要点
- Session vs JWT 选型:Session 强在「可随时踢人下线」(删服务端记录即可)、前端零成本,代价是服务端要存状态、多实例需共享存储(Redis);JWT 强在无状态、天然跨服务/跨域(App、小程序都好使),代价是签发后无法主动撤销。常见组合拳:短有效期 access token(15 分钟)+ 可撤销的 refresh token(存 Redis,可踢)。
- 密码存储三原则:只用 bcrypt/scrypt/argon2;只存哈希不存原文(连日志都不能打);校验失败信息模糊化(用户名或密码错误)。
- JWT 的信任来自签名不是加密:payload 透明可读,绝不能放手机号、密码等敏感信息;密钥
JWT_SECRET 从环境变量读,绝不提交进 Git(回想第 2 课的 .gitignore)。
- Token 的传输与存储:API 场景标准是
Authorization: Bearer <token>。Web 前端存哪里有取舍——localStorage 方便但 XSS 可偷;httpOnly Cookie 浏览器自动带、JS 读不到但要配 CSRF 防护。企业内两者都有,关键是理解各自的攻击面。
- 认证和授权分开实现:中间件只做「验明正身」(401),角色/权限判断是另一层(403)——
auth 之后可以再叠 requireRole('admin'),职责不要混在一起。
你可能会懵的地方
-
JWT 签发后想立刻让它失效,怎么办?
签名有效期内服务端默认认账,这是无状态的代价。三个办法:① 缩短有效期 + refresh token(主流);② 服务端维护黑名单(Redis 存被踢 token 的 jti),等于把一部分状态请回来;③ 关键操作(改密码)要求重新验证。改密码后让所有旧 token 失效 = 记录一个「最早有效时间」,payload 里的 iat 早于它就拒绝。
-
把 secret 硬编码在代码里会怎样?
仓库一旦泄露(公开仓库、员工离职拷代码),任何人可签发任意身份的合法 token,等于整个认证体系裸奔。正确做法:环境变量/密钥管理服务注入,不同环境用不同密钥。
-
为什么 bcrypt.compare 不需要单独传盐?
盐就拼在哈希结果的字符串里($2a$10$ 后面那串包含算法、cost 和盐),compare 时自动拆出来用。这是设计好的自描述格式,你不用管盐。
-
OAuth2 的四种模式都要记吗?
重点记授权码模式(上面流程图,最安全最常用,PKCE 是它的移动端/前端增强版);隐式模式已废弃;密码模式和客户端凭证知道适用场景(前者仅高度信任的第一方客户端,后者用于服务对服务)。
-
网关统一鉴权和各服务自己鉴权什么关系?
微服务架构常见做法:网关只做一次 JWT 验签,验完把用户信息通过请求头透传给内部服务;内部服务不再重复验签但必须只信任来自网关的流量。单体应用就是本课的中间件模式。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 密码明文或 MD5 存库 | 拖库即全军覆没,MD5 可被彩虹表秒破 | bcrypt/scrypt/argon2 |
| JWT payload 放手机号/角色敏感集 | payload 透明可读 | 只放 sub、过期时间等非敏感声明 |
| token 永不过期 | 泄露后永久有效 | 短有效期 + refresh token |
| 登录失败提示「密码错误」 | 给撞库者「用户名存在」的确认 | 统一提示「用户名或密码错误」 |
| JWT_SECRET 提交进仓库 | 任何人可伪造 token | 环境变量注入,.gitignore 排除 .env |
| 自己手写加密/签名算法 | 密码学自造轮子必翻车 | 用成熟库的成熟 API |
卡点急救
jwt.verify 报 JsonWebTokenError: invalid signature:签发和校验用了不同 secret(两个终端环境变量不一致是最常见原因),统一后重启。
- 报
TokenExpiredError:token 过期了,属预期行为。前端应捕获 401 后用 refresh token 换新,练习阶段重新登录即可。
- curl 带了 token 还是 401:检查头格式必须精确是
Authorization: Bearer <token>(Bearer 后有一个空格);token 复制时别带引号和换行。
- bcrypt.hash 报
data and hash arguments required:入参 undefined,多半是忘了 express.json() 或请求没带 body。
- session-demo 刷新后登录态丢失:默认存储在内存且 cookie 没回传。curl 要
-c cookies.txt(存)与 -b cookies.txt(带)配对使用;浏览器测试检查是否拦截了 Cookie。
面试常问
-
Session 和 JWT 的优缺点与选型?
Session:服务端有状态、易撤销、浏览器 Cookie 自动携带;缺点是多实例需共享存储、跨域/原生 App 不便。JWT:无状态易水平扩展、跨端通用;缺点是难撤销、payload 可读。选型:传统 Web 单体常用 Session;分布式/多端 API 常用 JWT(短效+refresh);能讲出「JWT 撤销问题的组合拳」是亮点。
-
JWT 由哪几部分组成?如何防篡改?
Header(算法)、Payload(声明)、Signature(对前两段用 secret + 指定算法签名),三段 Base64URL 以点相连。服务端重算签名比对,改一个字符即失配——所以防篡改靠签名,内容本身并不保密。
-
如何安全地存储用户密码?
专用慢哈希(bcrypt/argon2,内置随机盐、可调 cost),注册时哈希入库,登录时 compare;不使用 MD5/SHA 系;配合密码强度校验与登录限流。
-
OAuth2 授权码模式的流程与安全性?
跳授权页(client_id + redirect_uri + state 防 CSRF)→ 用户授权 → 带 code 回调 → 服务端用 code + client_secret 换 access_token → 调 API。密码不经过第三方网站;code 一次性且短时效;secret 只在服务端。
-
CSRF 是什么,和认证方案有什么关系?
跨站请求伪造:浏览器自动带 Cookie 的特性被恶意站点利用,冒充用户发请求。Session+Cookie 方案必须防(SameSite/CSRF Token);Bearer Token 放 Header 不自动携带,天然免疫 CSRF 但要防 XSS 偷 token。
自测三问
- 把
server.js 的用户存储从内存数组换成第 4 课的 PostgreSQL(用 pg 驱动即可),注册和登录需要改哪几处?
- 设计「管理员才能删除笔记」的鉴权链路:payload 放什么、中间件怎么叠加、401 和 403 分别在什么时机返回?
- 产品要求「用户改密码后所有设备下线」,用 JWT 怎么实现?说出至少两种方案及其代价。
小结一句话
认证 = 验明正身(401),授权 = 判定权限(403);密码交给 bcrypt,登录态在 Session(可踢人)和 JWT(无状态)之间按业务取舍,OAuth2 解决的是「借第三方身份而不碰对方密码」。
延伸