前言
我的校园论坛项目从 Vue3 + localStorage 模拟数据起步,到接入 MongoDB 实现数据持久化,再到今天完成 JWT 认证和权限控制——整个过程中踩过的坑、总结的模式,促使我写下这篇文章。
这是一份实战复盘。目标是梳理“JWT 认证到底做了什么”、“为什么要这样设计”、“中间踩了哪些坑”。如果你也在做自己的全栈项目,希望这篇文章能帮你少走一些弯路。
一、项目背景
技术栈:
- 前端:Vue3 (Composition API) + Pinia + Vue Router + Vite
- 后端:Node.js + Express(路由模块化)
- 数据库:MongoDB(本地部署 + Mongoose ODM)
- 认证:bcrypt 密码加密 + JWT 身份令牌
JWT 认证完成前的项目状态:
- 帖子 CRUD、评论(嵌套子文档)、用户注册登录均已从内存数组迁移至 MongoDB
- 密码使用 bcrypt 加密存储,登录时用
bcrypt.compare验证 - 任何人打开页面就能发帖、点赞、删别人的帖子——没有身份认证,也没有权限控制
JWT 认证的目标:
- 登录成功后,后端签发一个加密的 token(包含用户身份信息)
- 前端把这个 token 存起来,以后每次操作都带过去
- 后端收到请求时,先解析 token 确认“你是谁”,再决定“你能不能做这件事”
- 不同角色拥有不同权限:只有作者能删自己的帖子,只有评论者能改自己的评论,帖主可以删评论
二、JWT 认证核心流程
第一步:登录时签发 token
后端 routes/users.js 登录接口:
const jwt = require('jsonwebtoken');
// 登录成功后
const token = jwt.sign(
{ sno: user.sno, _id: user._id }, // 把用户信息编码进 token
process.env.JWT_SECRET, // 用密钥加密
{ expiresIn: '7d' } // 7 天后过期
);
res.json({ user: safeUser, token });
jwt.sign 做了三件事:接收要编码的数据、用密钥加密、设置过期时间。生成的 token 是一串由三部分组成的乱码(Header.Payload.Signature),可以通过 JSON.parse(atob(token.split('.')[1])) 解析出 Payload 看到里面的用户信息。
前端存储 token:
// user store 的 userLogin action 中
const data = await res.json()
currentUser.value = data.user
localStorage.setItem('forum-token', data.token)
退出登录时清除:
function logout() {
currentUser.value = null;
localStorage.removeItem('forum-token');
}
第二步:创建 auth 中间件解析 token
新建 middleware/auth.js:
const jwt = require('jsonwebtoken');
module.exports = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: '未登录,请先登录' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // 把用户信息挂到 req.user
next();
} catch (err) {
res.status(401).json({ error: 'Token 无效或已过期,请重新登录' });
}
};
关键理解:
- 前端发来的 token 在
req.headers.authorization里,格式是Bearer <token>,所以需要.split(' ')[1]取出 jwt.verify用同样的密钥解开 token,如果被篡改或过期会抛异常next()是中间件放行的信号,必须调用,否则请求会卡住
第三步:保护路由
发帖接口加 auth 保护前后对比:
// 改造前:任何人可以发帖
postRouter.post('/', async (req, res) => {
const authorId = '69edd547...' // 硬编码,不知道是谁
})
// 改造后:必须登录,自动知道是谁
postRouter.post('/', auth, async (req, res) => {
// ...
author: req.user._id // 从 token 里解析出来的用户 _id
})
auth 中间件会在进入路由之前先执行,检查 token、解析用户信息、挂到 req.user 上。如果没登录或 token 过期,直接返回 401,不会进入路由。
前端发帖请求加 token:
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('forum-token')}`
}
第四步:权限控制
只有帖子作者能删/改自己的帖子:
const post = await Post.findById(id)
if (post.author.toString() !== req.user._id) {
return res.status(403).json({ error: '只能编辑自己的帖子' })
}
点赞防重复:在 Post Schema 中新增 likedBy 数组记录点赞用户:
likedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }]
点赞时检查:
if (post.likedBy.includes(req.user._id)) {
return res.status(400).json({ error: '你已经点过赞了' })
}
评论权限双重检查:删除评论时,评论作者或帖主都能删:
if (user !== comment.author.toString() && user !== post.author.toString()) {
return res.status(403).json({ error: '无权限删除此评论' })
}
三、踩坑实录
坑一:post.author.toString() 为什么要加 .toString()
打印 typeof post.author 得到 "object",而 typeof req.user._id 得到 "string"。Mongoose 的 ObjectId 在代码里是对象,jwt.sign 转出来的 _id 是字符串——不同类型之间 === 永远为 false。必须用 .toString() 把 ObjectId 转成字符串才能比对。
坑二:编辑评论时用 findByIdAndUpdate 操作子文档
子文档不是独立集合,没有 findByIdAndUpdate 方法。正确做法是先找到父文档 post,再用 post.comments.id(commentId) 拿到子文档引用,直接改属性,最后 await post.save()。
坑三:前端评论请求忘记加 token
帖子模块所有请求(发帖、删除、编辑、点赞)都加好了 Authorization 头,但评论的三个请求还是旧版本。导致后端返回 401,前端只显示“删除失败”。前端新增、删除、编辑评论必须和帖子一样带 token。
四、犯错之后
我把这次 JWT 实现的完整流程梳理了一遍,并总结出来一个 接口保护模板:
router.METHOD('/path', auth, async (req, res) => {
// 1. 查资源
// 2. 判存在
// 3. 比对所有权(toString() !)
// 4. 执行操作
// 5. 返回结果
})
所有需要保护的路由都是这个模板的变种,唯一不同的是“比对所有权”那一行——帖子比 post.author,评论比 comment.author,点赞查 post.likedBy.includes。
另一个收获是“先验证核心链路,再补全其他接口”的调试策略。 先把发帖接口保护起来,确认登录、token 签发、中间件解析、req.user._id 写入整条链路跑通后,再按同样的模板补完删除、编辑、点赞、评论。
五、总结
完成 JWT 认证后,我的论坛现在拥有:
- 用户注册/登录,密码 bcrypt 加密
- JWT 身份令牌,登录签发,退出清除
- 所有写操作必须登录
- 只有作者能删/改自己的帖子
- 只有评论作者能编辑评论,帖主和评论作者能删评论
- 防重复点赞
终于从Demo变成一个有身份认证、有权限控制、有数据持久化的全栈产品。
下一步计划:UI 优化、部署上线。
这是我的掘金技术笔记,记录我在校园论坛项目中从“能用就行”到“理解为什么”的成长过程。