Node.js JWT 身份认证
上一篇建好了用户和文章的关联表,但接口谁都能调,这不行。这篇解决登录后怎么证明你是你,从 Session、Token 的区别讲起,到 JWT 的结构、密码加密、登录鉴权完整流程。这是后端开发的必考点,值得多看几遍。
认证要解决什么问题
HTTP 协议本身是无状态的,每次请求都是独立的,服务器不知道两次请求是不是同一个人。登录之后每次请求都要带上身份凭证,服务器验一下就知道你是谁了。
Session vs Token vs JWT
| 方案 | 凭证存放 | 特点 |
|---|---|---|
| Session | 服务器内存或数据库 | 有状态,服务端要存,多机部署麻烦 |
| Token | 客户端 | 无状态,服务端不存,但没法主动作废 |
| JWT | 客户端 | Token 的一种,带签名,可校验篡改 |
Session 需要服务端维护会话表,JWT 把用户信息签在凭证里,服务端只验签名不存数据,适合前后端分离的 API。
JWT 结构
JWT 由三段组成,用点号连接
Header.Payload.Signature
- Header 声明签名算法,比如 HS256
- Payload 放自定义数据
- Signature 用密钥对前两段签名
把 token 贴到 jwt.io 就能解码看到内容,Payload 显示出来大概是这样
{
"userId": 1,
"exp": 1785600000
}
Payload 里放什么有讲究,放 userId 和过期时间 exp 就够了,绝不能放密码。JWT 的 Payload 只做了 base64 编码,谁拿到都能读出来,签名只防篡改不防偷看。
密码加密
密码绝不允许存明文,数据库泄露就是全部账号泄露。用 bcryptjs 做哈希
npm install bcryptjs
const bcrypt = require("bcryptjs");
// 注册时加密存储
const hash = await bcrypt.hash("123456", 10);
console.log(hash); // $2a$10$...,每次结果都不同
// 登录时比对
const ok = await bcrypt.compare("123456", hash);
console.log(ok); // true
bcrypt 的第二个参数 10 是盐的轮数,加盐让相同密码产生不同哈希,配合内置盐值抵御彩虹表攻击。
登录流程
const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
// POST /api/login
async function login(email, password) {
const user = await prisma.user.findUnique({ where: { email } });
if (!user) throw new Error("邮箱或密码错误");
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) throw new Error("邮箱或密码错误");
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
);
return { token, user: { id: user.id, name: user.name } };
}
三步走,查用户、比对密码、签发 token。JWT_SECRET 放进 .env,参考 Node.js 环境变量与配置,密钥泄露等于所有人能伪造 token。
鉴权流程
客户端拿到 token 后,每次请求带在请求头里
Authorization: Bearer <token>
服务器端写一个中间件,在需要保护的接口前验证
// authMiddleware.js
const jwt = require("jsonwebtoken");
function authMiddleware(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith("Bearer ")) {
return res.status(401).json({ error: "未登录" });
}
const token = header.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.userId = payload.userId;
next();
} catch (err) {
return res.status(401).json({ error: "token 无效或已过期" });
}
}
module.exports = authMiddleware;
verify 通过就把 userId 挂到 req 上,后面的接口直接用 req.userId 定位当前用户。过期、篡改、签名不对都走 catch 分支,统一返回 401。
实践
给博客 API 加注册登录,用中间件保护两个接口。
// app.js
const express = require("express");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");
const authMiddleware = require("./authMiddleware");
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
const app = express();
app.use(express.json());
// 注册
app.post("/api/register", async (req, res) => {
const { name, email, password } = req.body;
const passwordHash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: { name, email, passwordHash }
});
res.status(201).json({ id: user.id, name: user.name });
});
// 登录
app.post("/api/login", async (req, res) => {
const { email, password } = req.body;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return res.status(401).json({ error: "邮箱或密码错误" });
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) return res.status(401).json({ error: "邮箱或密码错误" });
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {
expiresIn: "7d"
});
res.json({ token });
});
// 受保护接口,查自己
app.get("/api/users/me", authMiddleware, async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.userId } });
res.json(user);
});
// 受保护接口,发布文章
app.post("/api/posts", authMiddleware, async (req, res) => {
const post = await prisma.post.create({
data: {
title: req.body.title,
content: req.body.content,
authorId: req.userId
}
});
res.status(201).json(post);
});
app.listen(3000);
User 模型要加上 passwordHash 字段,记得跑迁移
model User {
id Int @id @default(autoincrement())
name String
email String @unique
passwordHash String
createdAt DateTime @default(now())
}
测试时先注册再登录拿 token,请求受保护接口带上请求头
curl -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"email":"xm@example.com","password":"123456"}'
curl http://localhost:3000/api/users/me \
-H "Authorization: Bearer <上一步返回的token>"
常见坑
- 密码存明文,上线就是事故,一律 bcrypt 哈希。
- Payload 里塞密码或手机号,JWT 是公开可读的,只放必要信息。
- 请求头漏写
Bearer前缀,verify 直接抛异常。 - JWT_SECRET 写死或太短,用随机长字符串并放 .env。
- 忘记处理 token 过期,过期后接口报错,统一在中间件返回 401。
- JWT 没法主动注销,踢人下线要引入黑名单,这是它的天生缺陷。
认证只是第一步,防止各种攻击的手段看下一篇,见 Node.js Web 安全。