六维教程

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 安全

上一篇
Node.js 数据库设计与关联
下一篇
Node.js Web 安全