首页
/ FreeCodeCamp 高级 Node 与 Express:用 BCrypt 为 Passport 本地认证项目实现密码哈希存储

FreeCodeCamp 高级 Node 与 Express:用 BCrypt 为 Passport 本地认证项目实现密码哈希存储

2026-09-04 16:19:36作者:昌雅子Ethen

本篇指南围绕 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-localLocalStrategy 里有一句用严格相等比较密码的代码:

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.jsondependencies 中,而不是 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');
  });

三个要点:

  1. 调用形式bcrypt.hashSync(plaintext, saltRounds) 是同步 API,直接返回哈希字符串,赋值给 const hash
  2. 成本因子取 12:第二个参数 12 即 salt rounds(cost factor)。BCrypt 的耗时随该值每增加 1 翻倍,12 是一个兼顾安全性与响应时间的常用取值;课程另一个「Information Security」代码块中的示例则使用了 13(见下文第四节),你可以理解为「取值越高越安全、也越慢」这一权衡关系。
  3. 只改入库字段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) 的参数顺序:第一个参数是用户刚输入的明文,第二个参数是存储的哈希,返回 truefalse。它内部会从哈希字符串自身解析出 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 部分)分两组,恰好对应上面两个改造点:

  1. 依赖检查:抓取运行中项目的 /_api/package.json,断言 dependencies 对象包含 bcrypt 属性——「Your project should list "bcrypt" as a dependency」。
  2. 代码检查:抓取 /_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」)会把这段逻辑拆进独立模块,但哈希与比对的调用方式不再变化。

登录后查看全文
热门项目推荐
相关项目推荐