ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Express/mongoose 密码加密实战:bcryptjs 在用户模型中的落地与校验

Express/mongoose 密码加密实战:bcryptjs 在用户模型中的落地与校验 1. Express mongoose 用户注册登录里密码明文落库到底有多危险先聊一个我真实踩过的坑。早期写 Express mongoose 的用户系统时我图省事Schema 里直接password: String注册接口User.create(req.body)一把梭。本地跑得飞起登录也正常直到有天导数据做备份打开 MongoDB Compass 一看——所有用户的密码整整齐齐躺在集合里明文一个字符都没少。那一刻的感觉就像你把家门钥匙插在门上还贴了张纸条写「欢迎光临」。这就是本篇要解决的核心问题在 Express mongoose 的用户注册/登录场景里用 bcryptjs 对密码做哈希存储与比对校验确保明文永远不落库。如果你正在写用户模块、做后台管理系统、或者接手了一个密码字段还是明文的项目这篇可以直接照着改。为什么明文存储这么要命我把它拆成几个层面说清楚你就知道这不是「可做可不做」的优化而是底线。第一数据库一旦泄露明文密码等于直接送账号。攻击者拿到库之后不需要任何破解直接拿去撞库。很多人多个平台用同一个密码你这边泄露别人的邮箱、支付、社交全跟着遭殃。哈希之后即使库被拖走攻击者拿到的是一串不可逆的摘要想还原成原始密码需要极大的算力成本。第二内部人员风险。开发、运维、DBA 都可能接触到数据库。明文存储意味着任何一个有查询权限的人都能看到用户密码。哈希之后连你自己都看不到原始密码这才是正确的姿态——系统里没有任何一个环节应该知道用户的明文密码。第三合规要求。现在个人信息保护相关的规范越来越严密码属于敏感个人信息存储时必须采取加密、去标识化等安全措施。明文存储一旦被审计或出事责任是跑不掉的。那为什么选 bcryptjs 而不是自己写个 MD5、SHA256这里有个关键认知普通哈希函数是为「快」设计的而密码哈希要的是「慢」。MD5、SHA256 计算极快攻击者用 GPU 每秒能算几十亿次配合彩虹表弱密码瞬间被还原。bcrypt 算法专门为密码设计它有两个杀手锏一是加盐salt。bcrypt 在哈希时会自动生成随机盐值并把它编码进最终的哈希串里。这意味着同样的密码两次哈希出来的结果完全不同彩虹表直接失效。你不需要自己管理盐bcryptjs 帮你处理好了。二是可调的计算轮数saltRounds / cost factor。轮数越高单次哈希耗时越长。攻击者暴力破解的成本被成倍放大而正常登录只多花几十毫秒用户几乎无感。一般建议 10 到 12 之间。bcryptjs 是 bcrypt 算法的纯 JavaScript 实现不需要编译原生模块npm i bcryptjs就能用在 Windows、Docker、Serverless 环境里都不会有 node-gyp 编译失败的糟心事。这也是我在 Express 项目里优先选它的原因——省心。下面我会从 Schema 字段设计、pre save 钩子、compare 方法配置一路写到注册写入和登录验证两步可复制的动作最后把常见的报错挨个排一遍。你跟着做就能把明文密码这个雷彻底拆掉。2. 接入前的准备TaoToken 前置配置与 bcryptjs 安装在动手改代码之前先把环境和依赖理清楚。这一节解决两件事一是把 bcryptjs 装好二是如果你打算用大模型辅助写这套用户模块比如让模型帮你生成 Schema、排查报错可以顺手把 TaoToken 的接入配好后面调试会方便很多。先说 bcryptjs 本身。它没有任何原生依赖安装就是一条命令npm i bcryptjs装完之后在需要的地方引入即可const bcryptjs require(bcryptjs);如果你用的是 ES Module 或 TypeScript写法是import bcryptjs from bcryptjs;这里提醒一个我踩过的坑网上有些老教程会让你装bcrypt不带 js那个是原生模块需要 node-gyp 编译在 Windows 上经常报gyp ERR!一堆错在 Alpine 镜像里也要额外装 python、make、g。除非你有极致的性能需求否则直接用bcryptjs就够了API 几乎一样。接下来是 TaoToken 的前置配置。TaoToken 是一个大模型 API 聚合平台你可以把它理解成一个统一的入口用同一套 Base URL 和 Key 去调用不同的模型。在写用户模块这种「需要反复调试、经常报错」的场景里有个顺手的模型对话入口能省不少时间——比如你不确定 bcrypt 的 saltRounds 设多少合适或者报了个data and hash arguments required看不懂直接丢给模型问比翻文档快。它的接入方式很标准兼容 OpenAI 风格的接口。你需要准备三样东西Base URL、API Key、Model ID。这三件套在后面的配置里会反复出现先记住。Base URL 统一用https://taotoken.net/apiAPI Key 需要你去控制台创建。打开这个地址https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentconsole进去之后找到 API Keys 页面新建一个 Key复制出来保存好。注意 Key 只在创建时完整显示一次关掉就看不到了建议立刻存到你的密码管理器或环境变量里。如果你想先快速验证一下 Key 能不能用、模型通不通可以用模型对话页面直接试https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentmodel-chat在页面里选一个模型发一句话能正常回复就说明 Key 和网络都没问题。这一步很重要因为后面如果你在代码里调不通先排除「Key 本身有问题」这个变量能少走很多弯路。如果你打算长期用模型辅助编码、跑 Agent 任务可以了解一下 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding-plan接入文档在这里遇到参数不确定的时候查它https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentdocAPI Keys 管理页再放一次方便你直接跳https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentapi-keys官网首页https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content好前置准备到这里。核心还是 bcryptjsTaoToken 是辅助你调试和写代码的工具。下面进入正题开始设计 Schema。3. 可复制的 Schema 字段设计与 pre save 钩子配置这一节是整篇的核心我会给你一份可以直接复制进项目的配置包含 Schema 字段设计、pre save 钩子、以及实例方法。路径和原文保持一致你新建一个models/user.js或者直接写在路由文件里都行。先看最基础的版本也就是很多教程里用的set写法const mongoose require(mongoose); const bcryptjs require(bcryptjs); const User mongoose.model( user, { username: String, password: { type: String, set(val) { // 每次数据库进行修改或插入时都会执行 set return bcryptjs.hashSync(val); }, }, }, user ); module.exports User;这个写法能跑set会在每次赋值时触发把明文替换成哈希值。但它有几个问题我必须指出来因为我在生产项目里被坑过第一set是每次赋值都触发。如果你从数据库查出一个用户改了个 username 再 savepassword 字段如果被重新赋值哪怕是同一个哈希值会被再哈希一次导致密码彻底失效。第二set里用hashSync是同步阻塞的高并发下会卡住事件循环。第三没有区分「新建」和「更新」逻辑不够清晰。所以我更推荐用pre save 钩子这也是官方和社区更认可的做法。它只在文档保存前触发并且可以判断isModified避免重复哈希。完整配置如下const mongoose require(mongoose); const bcryptjs require(bcryptjs); const userSchema new mongoose.Schema( { username: { type: String, required: [true, 用户名不能为空], unique: true, trim: true, }, password: { type: String, required: [true, 密码不能为空], select: false, // 查询时默认不返回密码字段 }, }, { timestamps: true, // 自动维护 createdAt / updatedAt } ); // pre save 钩子保存前对密码做哈希 userSchema.pre(save, async function (next) { // 只有密码被修改过才重新哈希避免更新其他字段时重复加密 if (!this.isModified(password)) return next(); try { const salt await bcryptjs.genSalt(10); // 生成盐轮数 10 this.password await bcryptjs.hash(this.password, salt); next(); } catch (err) { next(err); } }); // 实例方法校验密码 userSchema.methods.comparePassword async function (candidatePassword) { return bcryptjs.compare(candidatePassword, this.password); }; const User mongoose.model(user, userSchema, user); module.exports User;这份配置有几个关键点我逐个解释你照着理解就能举一反三。select: false这一行很实用。它让 password 字段在普通查询时默认不返回比如User.find()拿到的用户对象里没有 password。这样能防止你不小心把密码哈希返回给前端。需要校验密码时用.select(password)显式取出来。isModified(password)是防重复哈希的关键。只有密码字段真的变了才重新加密。用户改昵称、改头像时密码原样保留不会被动第二次。genSalt(10)hash是异步写法不阻塞事件循环。轮数 10 是性能和安全的平衡点实测单次哈希大概几十毫秒。如果你对安全要求更高可以调到 12但登录接口的响应时间会相应增加自己权衡。comparePassword是实例方法挂在文档上调用时是user.comparePassword(输入的密码)。它内部用bcryptjs.compare会自动从存储的哈希串里解析出盐值不需要你手动传盐。如果你更喜欢同步写法比如脚本里、或者对性能不敏感的场景可以这样userSchema.pre(save, function (next) { if (!this.isModified(password)) return next(); this.password bcryptjs.hashSync(this.password, 10); next(); }); userSchema.methods.comparePassword function (candidatePassword) { return bcryptjs.compareSync(candidatePassword, this.password); };hashSync(data, saltRounds)第二个参数直接传轮数它会自动生成盐。compareSync(data, encrypted)第一个参数是明文第二个是哈希串匹配返回 true。这里给你一个参数对照表方便你选方法是否阻塞参数适用场景hashSync(data, saltRounds)是明文、轮数脚本、初始化数据hash(data, salt)否明文、盐接口、高并发compareSync(data, encrypted)是明文、哈希串简单校验compare(data, encrypted)否明文、哈希串登录接口推荐配置写好后Schema 层面就完成了。接下来是路由层把注册和登录两个动作接上。4. 注册写入与登录验证两步可复制的验证请求Schema 配好了现在写路由。我按原文的风格用 Express Router 组织给你一份可以直接跑的代码。假设你的入口文件已经挂载了/api前缀数据库也连上了。先看注册接口const express require(express); const router express.Router(); const User require(../models/user); router.post(/regs, async (req, res) { try { const { username, password } req.body; if (!username || !password) { return res.send({ code: 400, message: 用户名和密码不能为空 }); } // 检查用户名是否已存在 const exist await User.findOne({ username }); if (exist) { return res.send({ code: 409, message: 用户名已存在 }); } // 直接 createpre save 钩子会自动哈希密码 await User.create({ username, password }); res.send({ code: 200, message: 注册成功 }); } catch (err) { res.send({ code: 500, message: err.message }); } }); module.exports router;注意这里User.create({ username, password })传的是明文密码但落库前会被 pre save 钩子替换成哈希值。你不需要在路由里手动调用 bcrypt职责分离得更干净。再看登录接口router.post(/login, async (req, res) { try { const { username, password } req.body; // 因为 Schema 里设了 select: false这里要显式取出 password const user await User.findOne({ username }).select(password); if (!user) { return res.send({ code: 401, message: 没有这个用户! }); } const isMatch await user.comparePassword(password); if (!isMatch) { return res.send({ code: 402, message: 密码错误! }); } res.send({ code: 200, message: 登录成功! }); } catch (err) { res.send({ code: 500, message: err.message }); } });这里有个细节因为 Schema 里 password 设了select: falsefindOne默认不返回密码所以必须加.select(password)否则user.password是 undefinedcompare会直接报错。这是很多人第一次用select: false时踩的坑。现在做两步验证。第一步启动服务用 curl 或 Postman 发注册请求curl -X POST http://localhost:3000/api/regs \ -H Content-Type: application/json \ -d {username:testuser,password:123456}预期返回{ code: 200, message: 注册成功 }第二步去数据库里确认密码是不是哈希值。用 MongoDB Compass 或命令行查一下mongosh use your_db db.user.find({ username: testuser })你会看到类似这样的结果{ _id: ObjectId(...), username: testuser, password: $2a$10$N9qo8uLOickgx2ZMRZoMye..., createdAt: ..., updatedAt: ... }password 字段是$2a$10$开头的哈希串明文123456完全不见了。这就是我们要的效果——明文不落库。第三步验证登录。先测正确密码curl -X POST http://localhost:3000/api/login \ -H Content-Type: application/json \ -d {username:testuser,password:123456}返回{ code: 200, message: 登录成功! }再测错误密码curl -X POST http://localhost:3000/api/login \ -H Content-Type: application/json \ -d {username:testuser,password:wrongpass}返回{ code: 402, message: 密码错误! }再测不存在的用户curl -X POST http://localhost:3000/api/login \ -H Content-Type: application/json \ -d {username:nobody,password:123456}返回{ code: 401, message: 没有这个用户! }三种情况都符合预期说明哈希存储和比对校验都正常工作了。到这里核心功能就完成了。如果你在调试过程中遇到报错看不懂可以把错误信息丢到模型对话里问比如https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentmodel-chat选个模型把报错和你的代码贴进去让它帮你定位。比一个人干瞪眼快得多。5. 常见报错排查401、data and hash arguments required 与重复哈希这一节我把实际项目里最常遇到的几个报错列出来对照着排查。这些错误我基本都踩过有的卡了我半天。报错一Error: data and hash arguments required这是 bcryptjs 最经典的报错。原因通常是compare的第二个参数是 undefined。在你的场景里八成是因为 Schema 设了select: false但查询时忘了加.select(password)导致user.password是 undefined。排查步骤在登录接口里console.log(user.password)如果是 undefined就加上.select(password)。另外如果你用的是set写法而不是 pre save检查一下是不是set没触发或者字段名拼错了。还有一种情况注册时密码没传进去或者传的是空字符串导致哈希出来是空。检查req.body里有没有 password。报错二登录一直返回 402 密码错误但密码明明是对的这个我遇到过原因是密码被哈希了两次。典型场景你用set写法然后更新用户信息时又 save 了一次password 被重新赋值set再次触发把已经是哈希值的字符串又哈希了一遍。之后你用原始密码 compare自然对不上。解决办法就是改用 pre save 钩子加上isModified(password)判断。如果你已经在用 set 写法检查一下更新逻辑里有没有无意中给 password 赋值。还有一种可能注册时用了hashSync登录时用了compareSync但注册的哈希串被截断了比如数据库字段长度不够。bcrypt 哈希串长度是 60 个字符确保你的数据库字段是 String 且没有长度限制。报错三MongoServerError: E11000 duplicate key error这是唯一索引冲突说明 username 重复了。如果你在 Schema 里给 username 设了unique: true插入重复用户名就会报这个。在注册接口里先findOne检查一下或者捕获这个错误返回友好提示。注意unique: true只是建索引不是校验器它不会在 save 前拦截而是靠数据库层报错。所以要么提前查要么 try catch。报错四local proxy failed或连接超时如果你在调用模型 API 时遇到这个先检查 Base URL 是不是写成了https://taotoken.net/api注意结尾不要多加斜杠。再检查 Key 有没有复制完整、有没有多余空格。网络层面确认你的服务器能正常访问外网。报错五401 Unauthorized调用模型接口返回 401基本就是 Key 的问题。可能原因Key 没传、传错位置、Key 被删除或过期。检查请求头是不是Authorization: Bearer YOUR_KEY。如果你用的是 SDK确认初始化时 apiKey 参数传对了。报错六reading choices或Cannot read properties of undefined这个通常出现在解析模型返回结果时。原因可能是返回结构和你预期的不一样或者请求失败了但你没检查错误就直接取response.choices[0]。加个判断if (!response || !response.choices || !response.choices.length) { throw new Error(模型返回异常: JSON.stringify(response)); }报错七OAuth 相关错误如果你在配置某些 CLI 工具时遇到 OAuth 报错检查你的认证方式是不是选错了。有些工具默认走 OAuth 流程但你想用 API Key需要在配置里显式指定。具体看对应工具的文档。为了让你排查更快我把这些报错和原因整理成表报错信息常见原因解决方向data and hash arguments requiredpassword 为 undefined加 .select(password)密码错误但密码正确密码被重复哈希用 pre save isModifiedE11000 duplicate keyusername 重复提前查重或捕获错误local proxy failedBase URL 或网络问题检查 URL 和连通性401 UnauthorizedKey 无效或未传检查 Authorization 头reading choices返回结构异常加空值判断排查的时候有个通用思路先确认数据在哪一层出了问题。是请求没进来是数据库没查到是哈希没生成还是比对逻辑错了在关键节点打日志比盲目改代码高效得多。如果你排查半天没头绪把完整的报错栈和相关代码贴到模型对话里让它帮你分析https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentmodel-chat接入相关的配置问题查文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentdoc6. 把密码安全做扎实从 bcryptjs 到完整用户模块的下一步到这里Express mongoose bcryptjs 的密码加密落地就完整了。你手上现在有一份能跑的 Schema、pre save 钩子、compare 方法以及注册和登录两个验证过的接口。明文密码不落库这个目标已经达成。但我想多说几句因为密码安全不是「加密了就完事」。bcryptjs 解决的是「存储环节」的问题而一个完整的用户系统还有几个地方值得你继续加固。第一传输环节。密码从浏览器到服务器如果是 HTTP 明文传输中间一样能被截获。生产环境必须上 HTTPS。这不是 bcryptjs 能管的但同样重要。第二登录失败次数限制。bcrypt 让暴力破解变慢但攻击者还是可以一直试。加个失败计数比如同一账号连续失败 5 次锁定 15 分钟能有效挡住撞库。这个逻辑可以放在 Redis 里用INCR和EXPIRE实现。第三密码强度校验。注册时限制最小长度、要求包含字母数字能挡住一批弱密码。bcrypt 再强用户设个123456也白搭。可以在 Schema 里加自定义 validator或者在路由层校验。第四不要把密码哈希返回给前端。这就是select: false的价值。养成习惯任何返回用户信息的接口都确认一下有没有把 password 带出去。我见过有人res.send(user)直接把整个文档返回哈希串虽然不可逆但也没必要暴露。第五升级路径。bcrypt 的轮数是可以随硬件升级而调整的。如果你现在用 10几年后觉得不够可以在用户下次登录成功时用新轮数重新哈希一遍再存回去。这叫「渐进式升级」不影响老用户。如果你打算把这套用户模块继续做下去比如加 JWT 鉴权、加角色权限、加第三方登录那编码量会上去调试也会更频繁。这时候有个顺手的模型辅助会轻松很多。长期做编码和 Agent 任务的话可以看看 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding-plan需要管理多个 Key、或者给团队分配额度去控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentconsole新建 Key 在这里https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentapi-keys最后给你一个我自己的经验密码相关的代码写完一定要手动验证一遍数据库。不要只看接口返回 200 就以为成了打开数据库确认存的是哈希值再用错误密码试一次确认比对逻辑生效。我见过太多「接口返回成功但密码还是明文」的情况都是因为钩子没触发或者字段名写错。花两分钟查库能省掉后面一堆麻烦。这套配置你直接复制就能用字段名、路径按你项目实际情况调整。核心逻辑不变明文进来哈希出去比对用 compare。把这三件事做对密码安全的地基就稳了。
返回列表