news 2026/10/3 7:09:10

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
配置mongoose实现登录和退登:从Schema到Session的完整落地

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-session(Session 方案)、jsonwebtoken(JWT 方案)、cookie-parser:

npm 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 内部做了恒定时间比较,能防时序攻击。

密码哈希为什么不用 md5?md5 是快速哈希,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 退登路由就是调revokeToken:

router.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: 'test@example.com', role: 'admin', }); console.log('seed done'); process.exit(0); })();

跑node seed.js,然后node app.js启动服务。

第一步,验证登录成功并拿到 Cookie 和 Token:

curl -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: sid=s%3A...; Path=/; HttpOnly; SameSite=Lax 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 访问受保护接口:

TOKEN="eyJhbGciOi..." # 替换成上一步拿到的 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:27017

MongoDB 没启动,或者连接字符串端口不对。先确认mongod进程在跑:ps aux | grep mongod。Docker 用户检查容器是否映射了 27017 端口。如果用了 MongoDB Atlas,检查连接字符串里的集群地址和 IP 白名单。

报错二:User.findOne(...).select is not a function

findOne返回的是 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 function

express-session没挂载,或者挂载顺序在路由之后。中间件必须在路由之前app.use(session({...}))。另外req.session为 undefined 时也会报这个,检查 session 中间件是否生效。

报错五:JWT 退登后旧 Token 还能用

黑名单没生效。检查三点:revokeToken是否真的被调用、isRevoked是否在jwt.verify之前执行、token 字符串是否完全一致(Bearer 前缀要去掉)。如果用了多进程部署,内存 Map 不共享,必须换 Redis。

报错六:JsonWebTokenError: invalid signature

JWT_SECRET 不一致。签发和验证用了不同的 secret,或者环境变量没加载。检查.env文件和process.env.JWT_SECRET的读取时机。

报错七:Cookie 没带上导致 401

curl 用-b cookies.txt,Postman 检查 Cookie 是否自动管理。浏览器里检查httpOnly和sameSite设置,跨域请求需要sameSite: 'none'+secure: true,且服务端要配 CORS 允许凭证。

报错八:Cannot read properties of null (reading 'comparePassword')

findOne没查到用户返回 null,直接调方法就报错。必须先判空再调comparePassword,上面的代码里已经做了if (!user)判断。

报错九:ValidationError: password: Path 'password' is required

Schema 里 password 设了required: true,但更新用户其他字段时没带 password。用user.save()会触发全量校验。解决办法:更新用User.updateOne绕过校验,或者user.save({ validateBeforeSave: false })。

报错十:MongoError: E11000 duplicate key error

username 唯一索引冲突。注册前先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 方案靠 destroy,JWT 方案靠黑名单或短过期,两条路选一条走通,别留半吊子实现。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 7:08:06

STM32参考设计资源平台全解析与实战改造指南

1. 找STM32参考设计,先搞清楚你要的到底是什么做STM32开发的人,几乎都经历过这个阶段:项目刚立项,芯片选好了,外设清单也列出来了,打开IDE准备建工程,突然发现——电源部分该怎么画?…

作者头像 李华
网站建设 2026/10/3 7:08:05

又一个快准稳满血 DeepSeek R1 接口,一分钟上手,送个体验 key

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 7:07:46

第十一章 验证与评估《程序员自进化与Agent Harness工程》:用退出码与LLM-as-judge搭建评测集,把TaoToken接入CI验证链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华