一、回顾与本篇目标
上一篇我们用 MySQL 数据库替换了 JSON 文件存储,实现了用户数据的增删改查。现在数据能持久保存了,但有一个关键功能还没做——用户注册和登录。
你用过无数需要注册登录的网站和应用。站在用户的角度,注册就是填个表单、点个按钮;登录就是输入账号密码、点个按钮。但站在后端的角度,注册和登录涉及一系列重要的安全问题:密码不能直接存、登录状态需要安全地传递、退出登录后 Token 要失效。
本篇的目标:
- 实现用户注册接口——密码加密存储
- 理解为什么要用哈希加密密码,以及“加盐”是什么
- 实现用户登录接口——验证密码并签发 Token
- 理解 JWT(JSON Web Token)的原理和结构
- 用中间件保护需要登录才能访问的接口
二、为什么不直接存密码
假设你做了一个网站,用户注册时填了密码,你就直接把它存到数据库里。某天,数据库被泄露了(这比你想的常见——大公司也会出这种事),攻击者拿到了所有用户的邮箱和密码。
由于很多人喜欢在多个网站用同一个密码,攻击者就可以用这些邮箱和密码去尝试登录其他网站——银行、邮箱、社交媒体。这就是“撞库”攻击。
所以永远不要存储明文密码。那怎么验证用户的密码呢?答案是哈希加密。
三、哈希加密:让密码变成不可逆的乱码
哈希是一种单向的数学运算:输入任意文本,输出一串固定长度的乱码。它有四个特点:
- 同样的输入永远得到同样的输出:每次用同一个密码计算哈希,结果是相同的。
- 输出长度固定:无论密码多长,哈希值都是一样长的字符串。
- 不可逆:从哈希值无法反推出原始密码。即使你拿到了哈希值,也不知道原始密码是什么。
- 输入微变,输出巨变:密码只改了一个字,哈希值完全不同。
登录验证的流程变成这样:
- 注册时:把用户密码用哈希算法算出一个哈希值,把哈希值存到数据库里。原始密码扔掉不存。
- 登录时:用户输入密码,服务器用同样的哈希算法算一遍,把算出来的哈希值和数据库里存的哈希值做对比。如果一样,说明密码正确。
这样即使数据库泄露,攻击者拿到的也是哈希值,而不是原始密码。从哈希值反推密码在数学上几乎不可能(除非密码本身就特别简单,比如 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 路由、中间件、用户认证、增删改查——从零搭建一个完整的留言板后端项目。你会看到这些技术是如何在一个真实项目中协同工作的。
后端零基础入门,每周更新。













暂无评论内容