九:用户认证——从密码到 Token

一、回顾与本篇目标

上一篇我们用 MySQL 数据库替换了 JSON 文件存储,实现了用户数据的增删改查。现在数据能持久保存了,但有一个关键功能还没做——用户注册和登录

你用过无数需要注册登录的网站和应用。站在用户的角度,注册就是填个表单、点个按钮;登录就是输入账号密码、点个按钮。但站在后端的角度,注册和登录涉及一系列重要的安全问题:密码不能直接存、登录状态需要安全地传递、退出登录后 Token 要失效。

本篇的目标:

  1. 实现用户注册接口——密码加密存储
  2. 理解为什么要用哈希加密密码,以及“加盐”是什么
  3. 实现用户登录接口——验证密码并签发 Token
  4. 理解 JWT(JSON Web Token)的原理和结构
  5. 用中间件保护需要登录才能访问的接口

二、为什么不直接存密码

假设你做了一个网站,用户注册时填了密码,你就直接把它存到数据库里。某天,数据库被泄露了(这比你想的常见——大公司也会出这种事),攻击者拿到了所有用户的邮箱和密码。

由于很多人喜欢在多个网站用同一个密码,攻击者就可以用这些邮箱和密码去尝试登录其他网站——银行、邮箱、社交媒体。这就是“撞库”攻击。

所以永远不要存储明文密码。那怎么验证用户的密码呢?答案是哈希加密

三、哈希加密:让密码变成不可逆的乱码

哈希是一种单向的数学运算:输入任意文本,输出一串固定长度的乱码。它有四个特点:

  1. 同样的输入永远得到同样的输出:每次用同一个密码计算哈希,结果是相同的。
  2. 输出长度固定:无论密码多长,哈希值都是一样长的字符串。
  3. 不可逆:从哈希值无法反推出原始密码。即使你拿到了哈希值,也不知道原始密码是什么。
  4. 输入微变,输出巨变:密码只改了一个字,哈希值完全不同。

登录验证的流程变成这样:

  1. 注册时:把用户密码用哈希算法算出一个哈希值,把哈希值存到数据库里。原始密码扔掉不存。
  2. 登录时:用户输入密码,服务器用同样的哈希算法算一遍,把算出来的哈希值和数据库里存的哈希值做对比。如果一样,说明密码正确。

这样即使数据库泄露,攻击者拿到的也是哈希值,而不是原始密码。从哈希值反推密码在数学上几乎不可能(除非密码本身就特别简单,比如 123456)。

四、加盐:防止彩虹表攻击

哈希虽然不可逆,但攻击者有一个取巧的办法——彩虹表

彩虹表是一个巨大的“常用密码 → 哈希值”对照表。比如:

123456  →  e10adc3949ba59abbe56e057f20f883e
password →  5f4dcc3b5aa765d61d8327deb882cf99
admin123 →  0192023a7bbd73250516f069df18b500

攻击者拿到数据库里存的哈希值后,不需要反推,只需要在彩虹表里搜一下,如果用户用的密码恰好是常见密码,瞬间就被匹配出来了。

加盐就是破解彩虹表的方法。盐是一个随机生成的字符串,每个用户一个不同的盐。哈希计算时,不是直接对密码做哈希,而是对“密码 + 盐”做哈希:

哈希值 = sha256(原始密码 + 随机盐)

数据库里存储哈希值两样东西。验证密码时,用数据库中存的盐和用户刚输入的密码重新算一遍哈希,看结果是否与数据库中存的哈希一致。

为什么加盐有效?因为加了盐之后,即使是两个使用相同密码的用户,由于随机盐不同,数据库里存的哈希值也完全不同。攻击者没法预先生成“所有密码 + 所有随机盐”的彩虹表,这数据量太大了。

五、用 bcrypt 实现安全的密码存储

实际开发中,我们不自己写哈希和加盐的逻辑,而是用经过验证的成熟库。bcrypt 是目前最广泛使用的密码哈希算法,它自动包含了加盐机制,而且故意设计得很慢——对正常用户来说几十毫秒察觉不到,但对暴力破解来说慢几万倍就是致命的。

安装 bcrypt

npm install bcrypt

基本用法

const bcrypt = require('bcrypt');

async function demo() {
  let password = 'mypassword123';

  // 生成哈希值(自动加盐,10 是计算强度,越大越安全也越慢)
  let hash = await bcrypt.hash(password, 10);
  console.log('哈希值:', hash);
  // 输出类似:$2b$10$xxxxx...(包含了盐和哈希值)

  // 验证密码
  let isMatch = await bcrypt.compare(password, hash);
  console.log('密码正确?', isMatch);  // true

  // 错误密码
  let isMatchWrong = await bcrypt.compare('wrongpassword', hash);
  console.log('错误密码验证:', isMatchWrong);  // false
}

demo();

关键点

  • bcrypt.hash(密码, 强度):返回的哈希值字符串里已经包含了盐。你不需要单独存盐。
  • bcrypt.compare(原始密码, 哈希值):自动从哈希值里提取盐,用同样算法算出哈希值并对比。返回 true 或 false。

六、实现注册和登录接口

现在把 bcrypt 整合到 Express 用户管理中。假设你已经有了上一篇的 db.js(MySQL 连接)和 app.js 基础结构。

数据库表调整

之前的 users 表只有 name 和 email,现在需要增加 password 字段用来存哈希值。在 MySQL 命令行执行:

ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL AFTER email;

如果之前已经有一些测试数据没有密码,可以先清空:

DELETE FROM users;

注册接口

const bcrypt = require('bcrypt');

app.post('/api/register', async function (request, response) {
  try {
    let { name, email, password } = request.body;

    // 简单验证
    if (!name || !email || !password) {
      return response.status(400).json({ error: '缺少必填字段:name、email、password' });
    }

    // 检查邮箱是否已注册
    let checkResult = await db.query('SELECT id FROM users WHERE email = ?', [email]);
    if (checkResult[0].length > 0) {
      return response.status(409).json({ error: '该邮箱已被注册' });
    }

    // 对密码进行哈希加密
    let hashedPassword = await bcrypt.hash(password, 10);

    // 存入数据库(存的是哈希值,不是原始密码!)
    let result = await db.query(
      'INSERT INTO users (name, email, password) VALUES (?, ?, ?)',

[name, email, hashedPassword]

); response.status(201).json({ message: ‘注册成功’, user: { id: result[0].insertId, name: name, email: email } }); } catch (error) { console.error(‘注册失败:’, error.message); response.status(500).json({ error: ‘服务器内部错误’ }); } });

关键代码

  • bcrypt.hash(password, 10):把用户的原始密码变成哈希值。10 是加密强度,值越大计算越慢也越安全。10 对大多数场景是合适的平衡点。
  • 数据库里存的是 hashedPassword(加密后的乱码),不是 password(原始密码)。
  • 返回给用户的响应里不要包含 password,即使是哈希值也不要返回。注册成功后只需要告诉用户基本信息即可。

登录接口

const jwt = require('jsonwebtoken');
const SECRET_KEY = 'your-secret-key-change-in-production';

app.post('/api/login', async function (request, response) {
  try {
    let { email, password } = request.body;

    if (!email || !password) {
      return response.status(400).json({ error: '缺少邮箱或密码' });
    }

    // 查找用户
    let result = await db.query('SELECT * FROM users WHERE email = ?', [email]);
    if (result[0].length === 0) {
      return response.status(401).json({ error: '邮箱或密码错误' });
    }

    let user = result[0][0];

    // 对比密码
    let isPasswordCorrect = await bcrypt.compare(password, user.password);
    if (!isPasswordCorrect) {
      return response.status(401).json({ error: '邮箱或密码错误' });
    }

    // 生成 Token
    let token = jwt.sign(
      { userId: user.id, email: user.email },
      SECRET_KEY,
      { expiresIn: '7d' }  // 7 天后过期
    );

    response.json({
      message: '登录成功',
      token: token,
      user: {
        id: user.id,
        name: user.name,
        email: user.email
      }
    });
  } catch (error) {
    console.error('登录失败:', error.message);
    response.status(500).json({ error: '服务器内部错误' });
  }
});

关键代码

  • bcrypt.compare(用户输入的密码, 数据库里的哈希值):验证密码是否正确。
  • 登录失败时统一返回“邮箱或密码错误”,不要明确告诉用户到底是邮箱不存在还是密码错误。这可以防止攻击者通过返回信息的不同来猜测哪些邮箱是注册用户。
  • jwt.sign() 生成 Token,这个下一节详细讲。

七、JWT:把登录状态变成一个 Token

HTTP 协议是无状态的——每次请求都是独立的,服务器不知道这个请求和上一个请求是不是同一个用户发的。但登录状态需要“保持”,这怎么解决?

传统的方案是 Session:服务器生成一个 Session ID 发给浏览器,浏览器每次请求都带上这个 ID,服务器根据 ID 查找对应的用户信息。但 Session 有一些不便:需要服务器端存储、不适合跨服务器部署。

JWT 是目前更流行的方案。它的核心思想是:把用户信息直接写在一个加密字符串里,发给客户端。客户端每次请求都带上这个字符串,服务器解开验证就能知道是谁。

JWT 的全称是 JSON Web Token。它的结构是三段用英文句号连接的字符串:

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjF9.7s8d9f7s8d7f8s7d8f7s8d7f8s

每一段的含义:

  • Header(头部):包含算法信息,比如“使用 HS256 算法加密”。
  • Payload(载荷):包含用户信息,比如用户 ID、邮箱。这段是 Base64 编码的,不是加密的,所以不要放敏感信息(如密码)在里面
  • Signature(签名):用密钥对前两段做哈希计算得到的签名。用来验证 Token 有没有被篡改。没有密钥的人无法伪造签名。

JWT 的验证逻辑

服务器收到 Token 后,用密钥重新计算签名,和 Token 里的签名对比:

  • 如果一致,说明 Token 是合法的,没有被改过。
  • 如果不一致,说明 Token 被篡改过(比如有人改了 Payload 里的用户 ID),拒绝这个 Token。

这就是 JWT 不需要服务器端存储的原因:用户信息就写在 Token 里,Token 的安全性靠签名保证。

安装 jsonwebtoken

npm install jsonwebtoken

生成 Token(在登录接口中已经展示了)

let token = jwt.sign(
  { userId: user.id, email: user.email },  // Payload:要存放的信息
  SECRET_KEY,                               // 密钥:只有服务器知道的字符串
  { expiresIn: '7d' }                       // 过期时间
);

验证 Token——用中间件保护接口

有些接口只有登录用户才能访问(比如查看个人信息、修改密码)。我们可以写一个中间件,在请求到达路由之前先检查 Token:

// authMiddleware.js —— 认证中间件

const jwt = require('jsonwebtoken');
const SECRET_KEY = 'your-secret-key-change-in-production';

function authMiddleware(request, response, next) {
  // 从请求头中获取 Token
  let authHeader = request.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return response.status(401).json({ error: '未登录,请先登录' });
  }

  // 提取 Token(去掉 "Bearer " 前缀)
  let token = authHeader.split(' ')[1];

  try {
    // 验证并解析 Token
    let decoded = jwt.verify(token, SECRET_KEY);
    // 把解析出来的用户信息挂到 request 上,后续的路由处理函数可以用
    request.user = decoded;
    next();  // 验证通过,继续往下走
  } catch (error) {
    return response.status(401).json({ error: 'Token 无效或已过期,请重新登录' });
  }
}

module.exports = authMiddleware;

使用中间件保护接口:

const authMiddleware = require('./authMiddleware.js');

// 获取当前登录用户的信息(需要登录)
app.get('/api/me', authMiddleware, async function (request, response) {
  try {
    // 中间件已经把用户信息放在 request.user 里了
    let userId = request.user.userId;

    let result = await db.query('SELECT id, name, email FROM users WHERE id = ?', [userId]);
    if (result[0].length === 0) {
      return response.status(404).json({ error: '用户不存在' });
    }

    response.json(result[0][0]);
  } catch (error) {
    response.status(500).json({ error: '服务器内部错误' });
  }
});

客户端(浏览器或 Postman)请求这个接口时,需要在请求头里加上:

Authorization: Bearer <登录时返回的 Token>

八、完整测试流程

用 Postman 测试整个注册登录流程:

1. 注册新用户

POST http://localhost:3000/api/register

Body → raw → JSON:

{
  "name": "张三",
  "email": "zhangsan@example.com",
  "password": "mypassword123"
}

返回注册成功信息和用户数据。

2. 用相同的邮箱再次注册

返回 409:“该邮箱已被注册”。

3. 登录

POST http://localhost:3000/api/login

{
  "email": "zhangsan@example.com",
  "password": "mypassword123"
}

返回登录成功信息和一个 Token。复制这个 Token。

4. 用 Token 访问受保护的接口

GET http://localhost:3000/api/me

Headers 里添加:Authorization: Bearer 刚才复制的Token

返回当前登录用户的信息。

5. 不带 Token 访问受保护接口

GET http://localhost:3000/api/me(不设置 Authorization 头)

返回 401:“未登录,请先登录”。

6. 登录时故意输错密码

返回 401:“邮箱或密码错误”。

九、登录接口应该返回什么状态码

登录失败时,状态码的选择其实有讲究。虽然很多网站用的是 400 Bad Request,但更符合 HTTP 规范的答案是 401 Unauthorized。它的含义是“需要身份验证”,正好对应“邮箱或密码错误”这种场景。

一个常见的细节:不要把“邮箱不存在”和“密码错误”区分成不同的错误提示。统一说“邮箱或密码错误”,防止攻击者通过错误信息差异来判断哪些邮箱已经注册。

十、把敏感的配置提取出来

上面的代码里 SECRET_KEY 是写死在代码里的。在实际项目中,密钥这类敏感信息应该通过环境变量来配置,而不是硬编码。

在项目根目录创建 .env 文件:

DB_HOST=localhost
DB_USER=root
DB_PASSWORD=123456
DB_NAME=myapp
JWT_SECRET=my-super-secret-key-change-in-production

安装 dotenv:

npm install dotenv

app.js 最顶部加载:

require('dotenv').config();

之后就可以通过 process.env.JWT_SECRET 来读取密钥。这样代码里不暴露敏感信息,换环境也只需要改 .env 文件。记得把 .env 加入 .gitignore,不要上传到公开仓库。

十一、本篇动手练习

练习 1:实现修改密码接口

增加 PUT /api/me/password 接口,要求提供旧密码和新密码。验证旧密码正确后才能更新为新密码。注意新密码要重新哈希存储。

练习 2:实现 Token 过期后重新登录的逻辑

把生成 Token 时的过期时间从 7 天改成 10 秒(expiresIn: '10s')。10 秒后用原来的 Token 访问受保护接口,观察返回的错误信息。

练习 3:给文章管理加权限

把之前的文章管理 API 加上认证保护:只有登录用户才能创建文章。在 POST /api/articles 路由前面加上 authMiddleware。同时,创建文章时把当前登录用户的 ID 作为作者存入数据库。

十二、本篇小结

这一篇我们实现了用户认证的完整流程:

  • 密码不能明文存储:用 bcrypt 哈希加密,自带加盐,防止数据库泄露后的密码暴露和彩虹表攻击。
  • 注册:接收用户名、邮箱、密码 → bcrypt 加密密码 → 存哈希值到数据库。
  • 登录:查邮箱 → 用 bcrypt.compare 验证密码 → 生成 JWT Token → 返回给客户端。
  • JWT 原理:三段结构(Header + Payload + Signature)。Payload 存用户信息,Signature 用密钥防篡改。不依赖服务器端存储。
  • 认证中间件:从 Authorization 头提取 Token → 用 jwt.verify 验证 → 解析出用户信息放到 request.user → 交给后面的路由处理。
  • 错误信息设计:登录失败统一返回“邮箱或密码错误”,不区分到底哪个错了。
  • 配置管理:用 .env 文件和 dotenv 管理密钥和数据库密码等敏感配置。

用户认证是后端开发中最基础也最核心的安全功能。理解了密码哈希、JWT、中间件认证这三者的配合,你就能为任何应用搭建用户系统。

下一篇预告

下一篇——《综合实战——做一个留言板后端》:我们将综合运用前面学到的所有知识——数据库设计、Express 路由、中间件、用户认证、增删改查——从零搭建一个完整的留言板后端项目。你会看到这些技术是如何在一个真实项目中协同工作的。

后端零基础入门,每周更新。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容