资讯动态

配置mongoose实现登录和退登:从Schema到Session的完整落地

发布时间:2026/10/3 7:09:11 来源:尧图企业网站定制
1. 从零搭一套能跑通的登录退登链路mongoose 是 Node.js 生态里操作 MongoDB 最常用的 ODM它把集合映射成 Schema、把文档映射成 Model让你用写对象的方式操作数据库。登录和退登是任何后台系统的第一道门登录要解决“密码怎么存、身份怎么记”退登要解决“身份怎么销毁、旧凭证怎么失效”。这套链路适合正在用 Node.js Express mongoose 写管理后台、API 服务的开发者尤其是已经能连上数据库、但登录态管理还停留在“明文密码 全局变量”阶段的同学。我见过太多项目把密码直接存明文或者登录后只在前端 localStorage 塞个用户名退登就是把 localStorage 清空——后端完全不知道用户是否登录过。这种写法在单机玩具项目里能跑一旦上线就是灾难数据库被拖库等于所有用户密码泄露退登后旧 Token 还能继续调接口。这篇按“Schema 设计 → 密码哈希 → Session/JWT 签发 → 退登销毁 → curl 验证”的顺序给你一份可以直接复制进项目的代码。数据库连接、User 模型、登录路由、退登路由、中间件鉴权全部覆盖最后用 curl 命令验证登录态和退登后 Token 是否真的失效。如果你在本地调试模型输出或需要对比不同模型的代码生成质量可以用 TaoToken 模型对话 快速验证路由逻辑省去反复改代码的时间。先说清楚两条技术路线你按项目情况选Session 方案登录成功后服务端生成 sessionId通过 Cookie 返回给客户端用户信息存在服务端内存或 Redis。退登时服务端销毁 session客户端 Cookie 失效。优点是服务端可控、能强制下线缺点是分布式部署需要共享 session 存储。JWT 方案登录成功后服务端签发一个签名 Token客户端自己保存Header 或 Cookie。退登时服务端无法直接“销毁”已签发的 JWT需要配合黑名单或短过期时间 refresh token。优点是服务端无状态、适合横向扩展缺点是退登不彻底。下面两套都写你可以按需取用。核心的 Schema 设计和密码哈希部分是共用的。2. mongoose 连接配置与 User Schema 设计先装依赖。Express 项目里需要 express、mongoose、bcryptjs密码哈希、express-sessionSession 方案、jsonwebtokenJWT 方案、cookie-parsernpm init -y npm install express mongoose bcryptjs express-session jsonwebtoken cookie-parser如果你用 TypeScript再加npm install -D typescript ts-node types/express types/mongoose types/bcryptjs types/express-session types/jsonwebtoken types/cookie-parser。数据库连接单独抽一个db.js不要写在 app.js 里方便测试和复用// db.js const mongoose require(mongoose); const MONGO_URI process.env.MONGO_URI || mongodb://127.0.0.1:27017/auth_demo; async function connectDB() { try { await mongoose.connect(MONGO_URI, { serverSelectionTimeoutMS: 5000, maxPoolSize: 10, }); console.log(MongoDB connected:, MONGO_URI); } catch (err) { console.error(MongoDB connection failed:, err.message); process.exit(1); } } module.exports { connectDB };几个参数说明serverSelectionTimeoutMS控制选主超时本地开发设 5000 够用生产可以调大maxPoolSize是连接池上限默认 100小项目设 10 避免占满 MongoDB 连接数。连接字符串里的auth_demo是数据库名MongoDB 会在首次写入时自动创建。User Schema 是整条链路的基石字段设计要考虑登录、退登、后续扩展// models/User.js const mongoose require(mongoose); const bcrypt require(bcryptjs); const userSchema new mongoose.Schema( { username: { type: String, required: [true, 用户名不能为空], unique: true, trim: true, minlength: [3, 用户名至少 3 个字符], maxlength: [32, 用户名最多 32 个字符], }, password: { type: String, required: [true, 密码不能为空], select: false, // 查询时默认不返回密码字段 }, email: { type: String, trim: true, lowercase: true, match: [/^\S\S\.\S$/, 邮箱格式不正确], }, role: { type: String, enum: [user, admin], default: user, }, lastLoginAt: { type: Date, default: null, }, loginCount: { type: Number, default: 0, }, }, { timestamps: true, // 自动维护 createdAt / updatedAt collection: users, } ); // 保存前自动哈希密码 userSchema.pre(save, async function (next) { if (!this.isModified(password)) return next(); const salt await bcrypt.genSalt(10); this.password await bcrypt.hash(this.password, salt); next(); }); // 实例方法校验密码 userSchema.methods.comparePassword function (plainPassword) { return bcrypt.compare(plainPassword, this.password); }; // 实例方法返回安全的用户信息不含密码 userSchema.methods.toSafeJSON function () { return { id: this._id, username: this.username, email: this.email, role: this.role, lastLoginAt: this.lastLoginAt, loginCount: this.loginCount, }; }; module.exports mongoose.model(User, userSchema);这里有几个关键点值得展开。select: false让密码字段在普通查询中不返回只有显式.select(password)才拿得到避免不小心把密码泄露到接口响应里。pre(save)钩子保证密码只在被修改时重新哈希更新其他字段不会重复加密。comparePassword用 bcrypt 的 compare 而不是自己写字符串比较bcrypt 内部做了恒定时间比较能防时序攻击。密码哈希为什么不用 md5md5 是快速哈希GPU 每秒能算几十亿次加盐也挡不住彩虹表 暴力破解。bcrypt 故意设计得慢genSalt(10)表示 2^10 轮单次哈希约 50-100ms暴力破解成本高几个数量级。生产环境可以用 12 轮登录接口 QPS 不高的话完全能接受。3. 可复制的登录与退登路由配置先写 Session 方案的完整配置。app.js 里挂载中间件和路由// app.js const express require(express); const session require(express-session); const cookieParser require(cookie-parser); const { connectDB } require(./db); const authRoutes require(./routes/auth); const app express(); app.use(express.json()); app.use(cookieParser()); app.use( session({ name: sid, secret: process.env.SESSION_SECRET || change-this-in-production, resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: false, // 生产环境走 HTTPS 时设为 true maxAge: 1000 * 60 * 60 * 2, // 2 小时 sameSite: lax, }, }) ); app.use(/api/auth, authRoutes); connectDB().then(() { app.listen(3000, () console.log(Server on http://localhost:3000)); });resave: false和saveUninitialized: false是官方推荐配置避免每次请求都重写 session、避免未登录用户也创建 session。httpOnly: true让 JS 读不到 Cookie防 XSS 窃取。sameSite: lax防 CSRF同时不影响正常跳转。登录和退登路由// routes/auth.js const express require(express); const jwt require(jsonwebtoken); const User require(../models/User); const router express.Router(); const JWT_SECRET process.env.JWT_SECRET || jwt-secret-change-me; const JWT_EXPIRES_IN 2h; // 登录 router.post(/login, async (req, res) { try { const { username, password } req.body; if (!username || !password) { return res.status(400).json({ code: 400, msg: 用户名和密码不能为空 }); } const user await User.findOne({ username }).select(password); if (!user) { return res.status(401).json({ code: 401, msg: 用户名或密码错误 }); } const isMatch await user.comparePassword(password); if (!isMatch) { return res.status(401).json({ code: 401, msg: 用户名或密码错误 }); } // 更新登录信息 user.lastLoginAt new Date(); user.loginCount 1; await user.save(); // Session 方案 req.session.userId user._id.toString(); req.session.role user.role; // JWT 方案二选一这里同时签发方便演示 const token jwt.sign( { userId: user._id.toString(), role: user.role }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN } ); res.json({ code: 0, msg: 登录成功, data: { user: user.toSafeJSON(), token }, }); } catch (err) { console.error(login error:, err); res.status(500).json({ code: 500, msg: 服务器内部错误 }); } }); // 退登 router.post(/logout, (req, res) { req.session.destroy((err) { if (err) { return res.status(500).json({ code: 500, msg: 退登失败 }); } res.clearCookie(sid); res.json({ code: 0, msg: 已退出登录 }); }); }); // 获取当前登录态 router.get(/me, async (req, res) { if (!req.session.userId) { return res.status(401).json({ code: 401, msg: 未登录 }); } const user await User.findById(req.session.userId); if (!user) { return res.status(401).json({ code: 401, msg: 用户不存在 }); } res.json({ code: 0, data: user.toSafeJSON() }); }); module.exports router;如果你用 JWT 方案做退登需要维护一个黑名单。简单做法是用内存 Map 存被吊销的 token生产环境换成 Redis// jwt 黑名单生产用 Redis const revokedTokens new Map(); function revokeToken(token) { const decoded jwt.decode(token); if (decoded decoded.exp) { revokedTokens.set(token, decoded.exp * 1000); } } function isRevoked(token) { const exp revokedTokens.get(token); if (!exp) return false; if (Date.now() exp) { revokedTokens.delete(token); return false; } return true; } // JWT 鉴权中间件 function authMiddleware(req, res, next) { const authHeader req.headers.authorization || ; const token authHeader.startsWith(Bearer ) ? authHeader.slice(7) : null; if (!token) { return res.status(401).json({ code: 401, msg: 缺少 Token }); } if (isRevoked(token)) { return res.status(401).json({ code: 401, msg: Token 已失效 }); } try { req.user jwt.verify(token, JWT_SECRET); next(); } catch (err) { return res.status(401).json({ code: 401, msg: Token 无效或已过期 }); } }JWT 退登路由就是调revokeTokenrouter.post(/logout-jwt, authMiddleware, (req, res) { const authHeader req.headers.authorization || ; const token authHeader.slice(7); revokeToken(token); res.json({ code: 0, msg: 已退出登录 }); });如果你在写 Claude Code 相关的 Agent 项目需要把登录态透传给模型调用链可以参考 Claude Code 接入文档 里的鉴权透传写法思路和这里的中间件一致。4. 用 curl 验证登录态与退登后 Token 失效先启动服务然后造一个测试用户。可以用 mongosh 直接插也可以写个注册接口。这里用 Node 脚本快速造数据// seed.js const { connectDB } require(./db); const User require(./models/User); (async () { await connectDB(); await User.deleteMany({ username: testuser }); await User.create({ username: testuser, password: test123456, email: testexample.com, role: admin, }); console.log(seed done); process.exit(0); })();跑node seed.js然后node app.js启动服务。第一步验证登录成功并拿到 Cookie 和 Tokencurl -i -X POST http://localhost:3000/api/auth/login \ -H Content-Type: application/json \ -d {username:testuser,password:test123456} \ -c cookies.txt预期响应HTTP/1.1 200 OK Set-Cookie: sids%3A...; Path/; HttpOnly; SameSiteLax Content-Type: application/json {code:0,msg:登录成功,data:{user:{id:...,username:testuser,role:admin,...},token:eyJhbGciOi...}}-c cookies.txt把 Set-Cookie 写进文件后续请求用-b cookies.txt带上。第二步用 Cookie 访问受保护接口验证登录态curl -i http://localhost:3000/api/auth/me -b cookies.txt预期返回 200 和用户信息。如果返回 401说明 session 没存上或 Cookie 没带上。第三步用 JWT 访问受保护接口TOKENeyJhbGciOi... # 替换成上一步拿到的 token curl -i http://localhost:3000/api/auth/me \ -H Authorization: Bearer $TOKEN第四步退登并验证旧凭证失效# Session 退登 curl -i -X POST http://localhost:3000/api/auth/logout -b cookies.txt # 再用旧 Cookie 访问 curl -i http://localhost:3000/api/auth/me -b cookies.txt # 预期 401 {code:401,msg:未登录} # JWT 退登 curl -i -X POST http://localhost:3000/api/auth/logout-jwt \ -H Authorization: Bearer $TOKEN # 再用旧 Token 访问 curl -i http://localhost:3000/api/auth/me \ -H Authorization: Bearer $TOKEN # 预期 401 {code:401,msg:Token 已失效}实测下来Session 方案退登后旧 Cookie 立刻失效因为服务端 session 已经 destroy。JWT 方案退登后旧 Token 进入黑名单鉴权中间件拦截返回 401。两者都能达到“退登后旧凭证不可用”的效果区别在服务端是否有状态。如果你需要批量验证不同模型的接口返回格式可以用 TaoToken API Keys 生成测试 Key配合 curl 脚本跑回归。5. 登录退登常见报错排查报错一MongooseServerSelectionError: connect ECONNREFUSED 127.0.0.1:27017MongoDB 没启动或者连接字符串端口不对。先确认mongod进程在跑ps aux | grep mongod。Docker 用户检查容器是否映射了 27017 端口。如果用了 MongoDB Atlas检查连接字符串里的集群地址和 IP 白名单。报错二User.findOne(...).select is not a functionfindOne返回的是 Query 对象.select(password)要链式调用在await之前。写成await User.findOne({ username }).select(password)是对的写成await User.findOne({ username })再.select就错了因为 await 之后拿到的是文档不是 Query。报错三登录返回 401 但密码明明是对的最常见的原因是密码被重复哈希。如果你在注册时手动调了bcrypt.hash又在pre(save)钩子里哈希了一次存进数据库的是“哈希的哈希”登录时comparePassword自然对不上。解决办法要么注册时传明文让钩子处理要么去掉钩子手动哈希二选一。判断方法查数据库看 password 字段长度bcrypt 哈希固定 60 字符如果超过 60 就是重复哈希了。报错四Error: req.session.destroy is not a functionexpress-session没挂载或者挂载顺序在路由之后。中间件必须在路由之前app.use(session({...}))。另外req.session为 undefined 时也会报这个检查 session 中间件是否生效。报错五JWT 退登后旧 Token 还能用黑名单没生效。检查三点revokeToken是否真的被调用、isRevoked是否在jwt.verify之前执行、token 字符串是否完全一致Bearer 前缀要去掉。如果用了多进程部署内存 Map 不共享必须换 Redis。报错六JsonWebTokenError: invalid signatureJWT_SECRET 不一致。签发和验证用了不同的 secret或者环境变量没加载。检查.env文件和process.env.JWT_SECRET的读取时机。报错七Cookie 没带上导致 401curl 用-b cookies.txtPostman 检查 Cookie 是否自动管理。浏览器里检查httpOnly和sameSite设置跨域请求需要sameSite: nonesecure: true且服务端要配 CORS 允许凭证。报错八Cannot read properties of null (reading comparePassword)findOne没查到用户返回 null直接调方法就报错。必须先判空再调comparePassword上面的代码里已经做了if (!user)判断。报错九ValidationError: password: Path password is requiredSchema 里 password 设了required: true但更新用户其他字段时没带 password。用user.save()会触发全量校验。解决办法更新用User.updateOne绕过校验或者user.save({ validateBeforeSave: false })。报错十MongoError: E11000 duplicate key errorusername 唯一索引冲突。注册前先findOne查重或者捕获 E11000 错误返回友好提示。注意unique: true只是建索引不是校验器并发注册仍可能撞。6. 把登录退登接进你的项目到这里Schema 设计、密码哈希、Session/JWT 签发、退登销毁、curl 验证、报错排查全部走完了。你可以直接把db.js、models/User.js、routes/auth.js三个文件复制进项目改一下连接字符串和 secret 就能跑。几个落地建议生产环境把SESSION_SECRET和JWT_SECRET放进环境变量不要硬编码Session 存储换成 Redis避免重启丢登录态JWT 过期时间设短一点15 分钟到 2 小时配合 refresh token 做续期登录接口加频率限制防暴力破解密码哈希轮数用 12安全性和性能平衡。如果你在写长期运行的编码 Agent 或需要多轮对话的登录态管理Coding Plan 里有按量计费的方案适合把这类鉴权逻辑接进自动化流程。控制台里可以看调用量和错误分布Console 的日志能帮你定位是登录接口挂了还是模型调用超时。最后提醒一句退登不是“前端清个变量”就完事服务端必须让旧凭证失效。Session 方案靠 destroyJWT 方案靠黑名单或短过期两条路选一条走通别留半吊子实现。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑