Node.js bcrypt.js JWT 登录注销,让 Codex 走 TaoToken 验证

发布时间:2026/9/16 1:49:01
Node.js bcrypt.js JWT 登录注销,让 Codex 走 TaoToken 验证 项目排期进入倒计时身份验证模块还没收口注册接口不能把密码明文丢进 MongoDB登录态不能随手种一个裸 cookie注销接口更不能只在前端把页面跳走。这就是 Node.js 开发者在登录注销上最常补的功课——用 bcrypt.js 把密码做成不可逆哈希用 JWT 把登录态封装成 cookie。把这三个接口交给 Codex 代写之前我会先让模型调用通道走到 TaoToken去官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 拿 API Key再把 Codex 的 Base URL 指过去免得写一半撞上额度或者切模型。TaoToken 在这里只解决「模型从哪儿来」signup/login/logout 的安全逻辑仍然由 bcrypt.js 与 JWT 自己完成。1. 让 Codex 先看懂 authController.js 再动手1.1 登录注销三个端点的安全底线原始教程里最容易被一带而过的部分其实是三个接口各自的「安全底线」。signup 不能只做 INSERT它要先回答这几个问题邮箱格式对不对、用户名和邮箱有没有被占用、密码长度够不够。这些问题都在真正写哈希之前发生顺序错了数据库里就会多出一堆脏数据。login 的底线更细用户不存在时接口不能直接返回「用户不存在」因为这样等于告诉攻击者这个用户名可以继续撞所以用户查不到时也要用空字符串去跑一次 bcrypt.compare让响应时间不会成为用户枚举的突破口。logout 则要反过来想JWT 是无状态的你在服务端没有 session 可删唯一能做的是把浏览器里的 cookie 立即过期。原文的res.cookie(jwt, , { maxAge: 0 })就是这个意图。这三个点想清楚后再让 Codex 去写或者审查 controller它给出来的代码才有意义。否则它只是把网上的模板粘一遍出了安全问题你根本不知道是哪一步埋下的雷。1.2 给 Codex 换一个不乱跳模型的通道TaoToken我自己的习惯是先让 Codex 通读原始教程里的 authController.js再让它按照同样逻辑生成项目代码。但 Codex 默认连的是官方接口额度用完后就得去控制台换 key、换模型代码写到一半被迫停下来非常打断思路。这里我换成 TaoToken 作为统一接入通道先到 TaoToken 注册并创建 API Key然后编辑~/.codex/config.toml# ~/.codex/config.toml model YOUR_MODEL_ID model_provider taotoken [model_providers.taotoken] name TaoToken base_url https://taotoken.net/api env_key TAOTOKEN_API_KEY保存后在终端里导出 Keyexport TAOTOKEN_API_KEYYOUR_API_KEY注意两点第一Key 从 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 创建网页和控制台是给人看的第二Base URL 填的是https://taotoken.net/api末尾不要加/v1TaoToken 的兼容通道会自己拼好路径。模型 ID 也不要凭记忆填以 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 模型广场当时的列表为准。2. User.js 与 connectDB.js用户模型和数据库连接先行2.1 npm 初始化与依赖清单先初始化项目然后安装原始教程里用到的那组依赖npm init -y npm install express mongoose jsonwebtoken bcryptjs dotenv cors cookie-parser npm install nodemon -Dcors在前后端分离部署时才会用到如果后端是服务端渲染可以先不引入。接着在package.json里补上开发脚本和 ES Module 配置{ scripts: { dev: nodemon backend/index.js, start: node backend/index.js }, type: module }我一般让 Codex 直接核对两件事依赖是否装全scripts 路径是否和目录结构一致。因为原始教程用的是backend/index.js如果你放在根目录start 脚本会报Cannot find module。2.2 Model/User.jsusername、email 唯一password 最少 6 位用户模型是整条链路的地基字段约束写松一点后面 signup 就要补更多的防御代码。原始教程在Model/User.js里定义了username、fullName、password、email其中username和email都带unique: truepassword带minLength: 6import mongoose from mongoose; const userSchema new mongoose.Schema( { username: { type: String, required: true, unique: true, }, fullName: { type: String, required: true, }, password: { type: String, required: true, minLength: 6, }, email: { type: String, required: true, unique: true, }, }, { timestamps: true } ); const User mongoose.model(User, userSchema); export default User;这里不要把password的select设为false因为 login 时要拿它出来对比。等到登录接口写完再把findOne的查询投影改成password这是后话。现在先把模型钉死Codex 后面生成 controller 时字段才不会对不上。2.3 connectDB.js 与 .env 里的 MONGO_URI / JWT_SECRET数据库连接和密钥管理是另一个容易翻车的地方。原始教程把连接逻辑抽到db/connectDB.js用MONGO_URI环境变量连接本地 MongoDBimport mongoose from mongoose; const connectMongoDB async () { try { const conn await mongoose.connect(process.env.MONGO_URI); console.log(MongoDB connected: ${conn.connection.host}); } catch (error) { console.error(Error connecting to MongoDB: ${error.message}); process.exit(1); } }; export default connectMongoDB;.env文件里至少要有两项一个是数据库连接串一个是 JWT 签名密钥MONGO_URImongodb://localhost:27017/auth-test JWT_SECRETGBfChCMY8MB7wnymkWhtoD3cRlA1CC5e6iWSBmnuaSg本地没有 MongoDB 也没关系用 Docker 起一个临时实例即可如果用 MongoDB Atlas把连接串换成 Atlas 提供的地址。JWT_SECRET生产环境必须换成足够长的随机字符串而且不能提交进 Git.gitignore里记得加上.env。3. signup 接口bcrypt.js 哈希与 JWT cookie 同时落位3.1 校验顺序与错误处理signup 的 controller 在原始教程里占了很大篇幅因为它把所有防御规则都堆在一起了。正确的执行顺序是先解构请求体再依次做邮箱格式校验、用户名查重、邮箱查重、密码长度校验然后才进入哈希环节。顺序颠倒会导致不必要的数据库查询或者让格式错误的请求也打到 MongoDB 上。以下是我整理后的controllers/authController.js中 signup 部分行为和原始教程一致但把错误信息写得更适合 curl 排查export const signup async (req, res) { try { const { fullName, username, email, password } req.body; const emailRegex /^[^\s][^\s]\.[^\s]$/; if (!emailRegex.test(email)) { return res.status(400).json({ error: Invalid email format }); } const existingUser await User.findOne({ username }); if (existingUser) { return res.status(400).json({ error: Username is already taken }); } const existingEmail await User.findOne({ email }); if (existingEmail) { return res.status(400).json({ error: Email is already taken }); } if (password.length 6) { return res .status(400) .json({ error: Password must be at least 6 characters long }); } const salt await bcrypt.genSalt(10); const hashedPassword await bcrypt.hash(password, salt); const newUser new User({ fullName, username, email, password: hashedPassword, }); await newUser.save(); generateTokenAndSetCookie(newUser._id, res); res.status(201).json({ _id: newUser._id, fullName: newUser.fullName, username: newUser.username, email: newUser.email, }); } catch (error) { console.log(Error in signup controller, error.message); res.status(500).json({ error: Internal Server Error }); } };这里最容易出错的一步是bcrypt.genSalt(10)之后再bcrypt.hash(password, salt)。如果你为了省事把盐值写死成固定字符串攻击者就能用彩虹表直接反查盐值的作用就是让同样的密码在不同用户身上产生不同哈希。3.2 generateTokenAndSetCookie.js令牌签名与 cookie 属性signup 成功后要立刻让浏览器持有登录态这一步由generateTokenAndSetCookie完成。原始教程把它单独放在utils/generateToken.js里是因为 login 也要复用同一段签名逻辑。import jwt from jsonwebtoken; export const generateTokenAndSetCookie (userId, res) { const token jwt.sign({ userId }, process.env.JWT_SECRET, { expiresIn: 15d, }); res.cookie(jwt, token, { maxAge: 15 * 24 * 60 * 60 * 1000, httpOnly: true, sameSite: strict, secure: process.env.NODE_ENV ! development, }); };这里四个 cookie 属性每个都有对应的攻击场景。httpOnly是为了不让 JavaScript 通过document.cookie读取令牌XSS 脚本拿不到它就很难冒充用户sameSite: strict限制跨站请求自动携带 cookieCSRF 的防线secure保证生产环境只在 HTTPS 下传输maxAge和 JWT 的expiresIn都设为 15 天两者一致才不会出现 token 还没过期但 cookie 先消失的情况。3.3 把 signup 挂到 /api/auth路由层相对简单原始教程在routes/authRoutes.js里用 Express Router 组织接口import express from express; import { signup } from ../controllers/authController.js; const router express.Router(); router.post(/signup, signup); export default router;注意这里用的是POST不是上一版占位路由里的GET。早期用GET /signup只是验证路由能不能命中真正提交用户数据只能用 POST否则密码会出现在浏览器历史里。4. login 用 bcrypt.comparelogout 用 maxAge: 04.1 login用户不存在也要走一次比较login 的 controller 核心是bcrypt.compare。原始教程里有一行很容易被忽略的代码user?.password || 。这行的意思是如果user为undefined就用空字符串去比较这样接口会返回「Invalid username or password」而不是直接抛Cannot read properties of undefined。同时因为无论如何都会执行一次 bcrypt 比较攻击者无法通过响应时间判断用户名是否存在。export const login async (req, res) { try { const { username, password } req.body; const user await User.findOne({ username }); const isPasswordCorrect await bcrypt.compare( password, user?.password || ); if (!user || !isPasswordCorrect) { return res.status(400).json({ error: Invalid username or password }); } generateTokenAndSetCookie(user._id, res); res.status(200).json({ message: Logged in successfully }); } catch (error) { console.log(Error in login controller, error.message); res.status(500).json({ error: Internal Server Error }); } };这里还有个取舍问题是先比较再判断用户存不存在还是先判断用户存不存在再比较原始教程是先findOne再bcrypt.compare最后用if (!user || !isPasswordCorrect)统一收口。这个顺序的好处是即使密码为空字符串bcrypt.compare也不会抛错因为 bcryptjs 能处理空字符串输入。4.2 logout清 cookie 而不是删 tokenlogout 不需要关心 token 本身是否合法只要把浏览器里的 cookie 标记为过期。原始教程的实现很简洁export const logout async (req, res) { try { res.cookie(jwt, , { maxAge: 0 }); res.status(200).json({ message: Logged out successfully }); } catch (error) { console.log(Error in logout controller, error.message); res.status(500).json({ error: Internal Server Error }); } };maxAge: 0会让浏览器立刻删除同名 cookie。如果你在调试时发现 logout 后 cookie 还在多半是响应头里缺少Set-Cookie或者请求带上了-b cookies.txt但响应没被浏览器解析。这里不需要调用 JWT 的销毁接口因为 JWT 本身无状态服务端没有会话可销毁。4.3 index.js 中间件与路由挂载顺序三个 controller 都写完最后在backend/index.js里把它们串起来。原始教程把connectMongoDB()放在app.listen的回调里这样端口先起来数据库再连接日志顺序更直观import express from express; import dotenv from dotenv; import authRoutes from ./routes/authRoutes.js; import connectMongoDB from ./db/connectDB.js; import cookieParser from cookie-parser; dotenv.config(); const app express(); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); app.use(/api/auth, authRoutes); app.get(/, (req, res) { res.send(Server is ready); }); app.listen(8000, () { console.log(Server is running on PORT 8000); connectMongoDB(); });express.json()和cookieParser()的顺序不能颠倒因为路由的处理函数依赖 body 解析和 cookie 解析结果。把这两个中间件放在路由之前req.body和req.cookies才能被正确填充。最后补全authRoutes.jsimport express from express; import { login, logout, signup } from ../controllers/authController.js; const router express.Router(); router.post(/signup, signup); router.post(/login, login); router.post(/logout, logout); export default router;5. curl 依次打 /signup、/login、/logout验证哈希和 cookie5.1 启动 MongoDB 与 nodemon配置和代码就绪后先在终端启动 MongoDB再运行npm run dev。如果你用的是 Docker可以用docker run -d -p 27017:27017 --name auth-test-mongo mongo:7起一个临时实例。然后启动服务npm run dev看到Server is running on PORT 8000和MongoDB connected: ...两行日志后再继续。只有数据库连接成功signup 才会真正写入数据。5.2 注册接口检查 MongoDB 里存的是 $2a$10$ 开头用 curl 发送第一个注册请求-i参数让响应头也显示出来curl -i -X POST http://localhost:8000/api/auth/signup \ -H Content-Type: application/json \ -d {fullName:Alice,username:alice,email:aliceexample.com,password:secret123}正常响应应该包含HTTP/1.1 201 Created和一个Set-Cookie: jwt...头。此时打开 MongoDB 验证存储的密码字段mongosh auth-test --eval db.users.find().pretty()password字段应当是$2a$10$开头的 60 位字符串例如$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy。如果你看到明文secret123说明 bcrypt 哈希这一步没有生效回到 3.1 检查是否真的调用了bcrypt.hash。5.3 登录接口检查 Set-Cookie 里的 jwt 与 Max-Age登录时把响应里的 cookie 存到文件方便下一步 logout 使用curl -i -X POST http://localhost:8000/api/auth/login \ -H Content-Type: application/json \ -d {username:alice,password:secret123} \ -c cookies.txt在响应头的Set-Cookie里你应该看到jwteyJhbGciOiJIUzI1NiIs...后面跟着Max-Age129600015 天单位秒、Path/、HttpOnly、SameSiteStrict。如果Set-Cookie缺失检查cookieParser()是否在路由之前注册以及generateTokenAndSetCookie是否被调用。错误密码的场景也值得测一次curl -i -X POST http://localhost:8000/api/auth/login \ -H Content-Type: application/json \ -d {username:alice,password:wrongpassword}预期返回400 Invalid username or password并且没有Set-Cookie。这一步能验证bcrypt.compare是否真的在比较哈希而不是把传入密码直接与数据库原文比对。5.4 注销接口确认 jwt cookie 被清成 maxAge0带上刚才保存的 cookie 请求 logoutcurl -i -X POST http://localhost:8000/api/auth/logout -b cookies.txt响应头的Set-Cookie应该是jwt; Max-Age0; Path/; ExpiresThu, 01 Jan 1970 00:00:00 GMT。这说明 cookie 已被浏览器端标记为过期。如果你继续用-b cookies.txt请求后续受保护路由应该收到未授权的报错。原始教程用的是 Postman我这里换成 curl 只是同一套请求的不同发送方式。无论是 Postman 里的 Headers 面板还是 curl 的-i输出核心观察点都是signup 后数据库出现 bcrypt 哈希login 后响应头出现 jwt cookielogout 后 cookie 的 Max-Age 变成 0。6. 跑通后去控制台对一下 TaoToken 这次的调用6.1 按报错倒查Mongo、JWT_SECRET、bcrypt、cookie、401我把实际操作中可能碰到的报错按出现频率排个序方便你照着排查。MongooseServerSelectionError: connect ECONNREFUSEDMongoDB 没启动先跑docker ps或者mongosh确认数据库进程。secretOrPrivateKey must have a value.env里的JWT_SECRET没加载检查dotenv.config()是否在jwt.sign之前调用。data and hash arguments requiredbcrypt.compare的第一个参数或第二个参数变成undefined通常是请求体字段没对上检查 curl 发送的 JSON 字段名。signup 成功但 login 始终Invalid username or password大概率是注册时把明文密码存进库了回到 3.1 确认new User时传入的是hashedPassword而不是password。响应头看不到Set-Cookie检查跨域配置如果前端页面跑在 5173 端口而后端是 8000先确认为本地联调临时关掉sameSite或配置 CORS 白名单纯 curl 测试则优先检查cookieParser()。Codex 走 TaoToken 时如果返回 401先查三件事~/.codex/config.toml里env_key对应的环境变量是否已导出、模型 ID 是否在模型广场存在、Base URL 是否误加/v1。这个 401 和登录接口的 400 不是一回事前者是模型调用通道没认账后者才是业务逻辑没通过。6.2 同一把 Key 去模型对话再看 Coding Plan 与控制台三个端点全部验证通过后回到 TaoToken 模型对话用同一把 Key 发一条测试消息确认模型 ID 和 Base URL 没配错。如果接下来一周都要高密度写 Node.js 后端可以顺便看一下 Coding Plan 的套餐是否够这次登录注销模块的反复调试用量。新建 Key 和管理限额都在 控制台 API Keys 页面如果你同时用 Claude Code环境变量对照表见 接入文档。到控制台看到这次调用正常入账再继续下一个接口。TaoToken 只负责把 Codex 需要的模型稳定送过来这套登录注销链路能不能扛住生产流量仍然取决于 bcrypt.js 的盐值哈希、JWT 的签名有效期以及 logout 时那个maxAge: 0是否被严格执行。