FreeCodeCamp 高级 Node 与 Express:用 BCrypt 为 Passport 本地认证项目实现密码哈希存储
本篇指南围绕 freeCodeCamp 课程库中「Advanced Node and Express(高级 Node 与 Express)」实战项目的挑战《Hashing Your Passwords》展开,讲解如何在已搭好 Passport 本地策略的 Express 应用中引入 BCrypt,分别在「注册写库」与「登录校验」两个关键位置完成密码哈希化改造。读完后,你能完整复现该安全改造的两处代码变更、理解 hashSync / compareSync 的参数含义与哈希字符串结构,并知道项目测试是如何自动验证这些改动的。
一、背景:明文密码是如何进入数据库的
在 freeCodeCamp 的课程结构里,「Hashing Your Passwords」是「Advanced Node and Express」代码块中的一个顺序挑战,位于「Registration of New Users(新用户注册)」之后、「Clean Up Your Project with Modules(模块化整理)」之前,这一点可以从 代码块结构文件 中的 challengeOrder 数组直接确认。
回到课程信息安全的既有知识:存储明文密码是绝对不可接受的。而在这个实战项目里,明文密码正是前面挑战自己写进去的。
第一处明文入口:注册路由。 在「Registration of New Users」挑战中(见 注册挑战文档),POST /register 路由在确认用户名不存在后,会把用户直接插入数据库:
myDataBase.insertOne({
username: req.body.username,
password: req.body.password
},
(err, doc) => {
if (err) {
res.redirect('/');
} else {
// The inserted document is held within
// the ops property of the doc
next(null, doc.ops[0]);
}
}
)
这里 req.body.password 就是用户在表单里填写的明文密码。
第二处明文比较:Passport 本地策略。 在「Authentication Strategies」挑战中(见 认证策略挑战文档),passport-local 的 LocalStrategy 里有一句用严格相等比较密码的代码:
passport.use(new LocalStrategy((username, password, done) => {
myDataBase.findOne({ username: username }, (err, user) => {
console.log(`User ${username} attempted to log in.`);
if (err) return done(err);
if (!user) return done(null, false);
if (password !== user.password) return done(null, false);
return done(null, user);
});
}));
password !== user.password 这种明文相等判断,在密码被哈希化之后将永远比较失败——因为数据库里的 user.password 将不再可能是输入密码本身。
这就是《Hashing Your Passwords》要解决的问题:一次性解决存储与校验两端。完整的目标挑战文档见 Hashing Your Passwords。
二、引入依赖:bcrypt@~5.0.0 已就绪
挑战文档指出,bcrypt@~5.0.0 已经作为依赖添加到项目脚手架中,你只需要在 server 里把它 require 进来:
const bcrypt = require('bcrypt');
按照挑战测试的写法(其断言正则要求引号风格前后一致,即 require...('|")bcrypt('|")),单引号或双引号均可,但 require 的模块名必须精确为 bcrypt,且该依赖必须出现在 package.json 的 dependencies 中,而不是 devDependencies——测试会先抓取 /_api/package.json 并断言 dependencies.bcrypt 属性存在。
三、改造点一:注册路由中先哈希、再入库
挑战文档给出了明确的插入位置与代码:在注册路由的数据库写入逻辑之前加一行哈希,然后把入库对象里的明文替换掉。
app.route('/register')
.post((req, res, next) => {
// 新增:在数据库逻辑之前生成哈希
const hash = bcrypt.hashSync(req.body.password, 12);
myDataBase.findOne({ username: req.body.username }, (err, user) => {
if (err) {
next(err);
} else if (user) {
res.redirect('/');
} else {
myDataBase.insertOne({
username: req.body.username,
password: hash // 原来是 req.body.password
},
(err, doc) => {
if (err) {
res.redirect('/');
} else {
next(null, doc.ops[0]);
}
}
)
}
})
},
passport.authenticate('local', { failureRedirect: '/' }),
(req, res, next) => {
res.redirect('/profile');
});
三个要点:
- 调用形式:
bcrypt.hashSync(plaintext, saltRounds)是同步 API,直接返回哈希字符串,赋值给const hash。 - 成本因子取 12:第二个参数
12即 salt rounds(cost factor)。BCrypt 的耗时随该值每增加 1 翻倍,12 是一个兼顾安全性与响应时间的常用取值;课程另一个「Information Security」代码块中的示例则使用了 13(见下文第四节),你可以理解为「取值越高越安全、也越慢」这一权衡关系。 - 只改入库字段:
insertOne的第二个字段由password: req.body.password换成password: hash,其余注册流程(findOne查重、passport.authenticate('local')自动登录、重定向/profile)保持不变。
四、改造点二:本地策略中用 compareSync 校验密码
注册端改完后,数据库里的 user.password 已经是哈希值,登录策略里的明文比较必须同步升级。挑战文档特别提示:先观察原语句的语义——它是「如果不相等,就返回未认证」,即「比较失败即提前返回」的早退风格。把这一语义保留下来,新代码就是对 compareSync 的布尔结果取反:
if (!bcrypt.compareSync(password, user.password)) {
return done(null, false);
}
改完后整个策略的完整形态为:
passport.use(new LocalStrategy((username, password, done) => {
myDataBase.findOne({ username: username }, (err, user) => {
console.log(`User ${username} attempted to log in.`);
if (err) return done(err);
if (!user) return done(null, false);
if (!bcrypt.compareSync(password, user.password)) {
return done(null, false);
}
return done(null, user);
});
}));
这里的关键是 bcrypt.compareSync(candidate, hash) 的参数顺序:第一个参数是用户刚输入的明文,第二个参数是存储的哈希,返回 true 或 false。它内部会从哈希字符串自身解析出 salt,再用相同的 salt 重新计算候选明文并与哈希比对,因此无需额外传递盐值。
这也解释了为什么哈希必须「自带盐」:课程「Information Security」代码块中讲解同步哈希的挑战(见 sync hash/compare 挑战)指出,即使两次对同一密码做哈希,输出也不同——因为盐是每次随机生成的,它体现在哈希字符串第三段的前 22 个字符中。例如课程示例给出的哈希:
$2a$12$Y.PHPE15wR25qrrtgGkiYe2sXo98cjuMCG1YwSI5rJW1DSJp0gEYS
从字符串结构看,它由四段组成:$2a$(算法版本)、12(成本因子)、Y.PHPE15wR25qrrtgGki(22 字符盐)与其余部分(摘要)。compareSync 正是依据这个自描述格式完成比对的。
五、同步还是异步:hashSync 在这个项目中的取舍
《Hashing Your Passwords》选用了同步 API(hashSync / compareSync),而课程「Information Security」代码块中的配套挑战(见 异步哈希挑战)则给出了这样的原则性提示:由于哈希被设计成计算密集型操作,生产环境更推荐异步执行,避免阻塞正在处理的连接:
bcrypt.hash(myPlaintextPassword, saltRounds, (err, hash) => {
/*Store hash in your db*/
});
bcrypt.compare(myPlaintextPassword, hash, (err, res) => {
/*res == true or false*/
});
两者的权衡可以概括为:本实战项目注册与登录流量有限、代码简洁优先,因此挑战文档选择了同步 API,一行 const hash = ... 就能接在数据库逻辑之前;而在高并发服务端,回调式(或 Promise 化)的 bcrypt.hash / bcrypt.compare 是更稳妥的写法。理解这一点后,你可以根据场景在两者之间自由切换,接口参数含义完全一致。
六、自动验证:项目测试如何确认改造完成
该挑战的测试断言(完整内容见 挑战文档的 hints 部分)分两组,恰好对应上面两个改造点:
- 依赖检查:抓取运行中项目的
/_api/package.json,断言dependencies对象包含bcrypt属性——「Your project should list "bcrypt" as a dependency」。 - 代码检查:抓取
/_api/server.js的源码文本,用三个正则分别验证:/require.*("|')bcrypt\1/gi:bcrypt 已被 require(注意捕获组\1要求两处引号风格一致);/bcrypt.hashSync/gi:注册环节使用了哈希('You should use hash the password in the registration');/bcrypt.compareSync/gi:策略中用 compareSync 校验('You should compare the password to the hash in your strategy')。
这些断言说明:仅把密码「看起来」改对是不够的,同步 API 的两个函数名必须原样出现在 server 代码中,且依赖必须声明在正式依赖里。改造完成后提交页面,若遇到运行错误,课程文档建议对照官方论坛中该项目的阶段性完成版本排查(论坛链接不在本文范围内,可按 forumTopicId 301553 自行检索)。
七、小结:两处变更构成的完整闭环
把整个挑战浓缩成一次 diff,就是这两处:
| 位置 | 改造前 | 改造后 |
|---|---|---|
POST /register 入库前 |
无 | const hash = bcrypt.hashSync(req.body.password, 12); |
insertOne 密码字段 |
password: req.body.password |
password: hash |
LocalStrategy 密码校验 |
if (password !== user.password) return done(null, false); |
if (!bcrypt.compareSync(password, user.password)) { return done(null, false); } |
这正是挑战文档最后一句所强调的:完成这两处修改,就已经实现了「必须存储密码时的最重要安全特性之一」。从工程视角看,这条改造链覆盖了密码数据的完整生命周期——写入(单向哈希入库)、读取(永不还原、只比对)、失败处理(done(null, false) 统一走 failureRedirect)。后续课程里的模块化整理(下一挑战「Clean Up Your Project with Modules」)会把这段逻辑拆进独立模块,但哈希与比对的调用方式不再变化。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00