实现刷新令牌轮换时为何仅第61行.save()抛VersionError
问题描述
近期调整项目Controller层代码实现刷新令牌轮换(refresh token rotation)逻辑,修改涉及refreshTokenController文件:
- 原有逻辑:服务端收到携带有效刷新令牌Cookie的API请求时,直接为客户端返回新的访问令牌
- 修改后异常:接口收到请求时抛出
VersionError错误
经初步定位: - 仅注释掉代码第61行的第三个
.save()调用时,报错会消失 - 单独或同时注释第32行、第49行的前两个
.save()调用,都无法消除报错
对应代码如下:
const jwt = require('jsonwebtoken'); const { defineModel } = require('../services/dbHandler'); const { userSchema } = require('../schemas/userSchema'); const { authTokenErr, cookieOptions, serverErr } = require('../utils'); const refreshAccessToken = async (req, res) => { const prevRefreshToken = req.cookies?.refToken; if (!prevRefreshToken) return res.status(401).json(authTokenErr); res.clearCookie('refToken', prevRefreshToken, cookieOptions); try { const User = await defineModel('global', 'User', userSchema); const dbUserWithToken = await User.findOne({ refreshToken: prevRefreshToken, }).exec(); if (!dbUserWithToken) { jwt.verify( prevRefreshToken, process.env.REFRESH_TOKEN_SECRET, async (err, payload) => { if (err) return res.status(401).json(authTokenErr); const compromisedDbUser = await User.findOne({ _id: payload._uid, }).exec(); compromisedDbUser.refreshToken = []; await compromisedDbUser.save(); } ); return res.status(401).json(authTokenErr); } const updatedTokenArr = dbUserWithToken.refreshToken.filter( (token) => token !== prevRefreshToken ); jwt.verify( prevRefreshToken, process.env.REFRESH_TOKEN_SECRET, async (err, payload) => { if (err || payload._uid !== dbUserWithToken._id.toString()) { dbUserWithToken.refreshToken = [...updatedTokenArr]; const result = await dbUserWithToken.save(); return res.status(401).json(authTokenErr); } const renewedRefreshToken = dbUserWithToken.generateRefreshToken(); const renewedAccessToken = dbUserWithToken.generateAccessToken(); dbUserWithToken.refreshToken = [ ...updatedTokenArr, renewedRefreshToken, ]; await dbUserWithToken.save(); // <---- 触发VersionError的位置 res.cookie('refToken', renewedRefreshToken, cookieOptions); return res.status(200).json({ accessToken: renewedAccessToken }); } ); } catch (err) { res.status(500).json(serverErr); console.error(err); } }; module.exports = { refreshAccessToken };
排查与修复方案
首先明确:Mongoose抛出VersionError的核心原因是当前持有的文档实例内部版本号(默认字段为__v)和数据库中该记录的最新版本号不匹配,被Mongoose的乐观并发控制机制拦截,避免旧数据覆盖新的修改。
结合代码逻辑,按以下优先级排查修复:
- 第一优先级:修复异步回调带来的时序混乱与悬空Promise问题
现有代码使用jwt.verify的回调模式,回调内的所有数据库逻辑不受外层try/catch捕获,且第20-37行的分支中,代码不会等待jwt回调内的compromisedDbUser.save()执行完成就直接返回响应,这部分悬空执行的数据库操作很容易和同请求/并发请求的操作产生竞态,触发版本冲突。
直接将jwt.verify改为同步调用(jsonwebtoken原生支持不传回调时同步返回结果、同步抛出验签错误),让所有逻辑严格串行执行,彻底消除异步回调的时序问题,改法参考:
改完后所有数据库操作都可以被外层try/catch捕获,不会出现悬空的异步save操作,绝大多数场景下版本错误会直接消失。// 移除所有jwt.verify的回调写法,统一用同步方式验签 let payload try { payload = jwt.verify(prevRefreshToken, process.env.REFRESH_TOKEN_SECRET) } catch (verifyErr) { // 验签失败逻辑直接写在这里,无需嵌套回调 if (dbUserWithToken) { dbUserWithToken.refreshToken = [...updatedTokenArr] await dbUserWithToken.save() } return res.status(401).json(authTokenErr) } // 验签通过后再做uid匹配、token生成、保存的逻辑,全部在同一异步栈内串行执行 if (payload._uid !== dbUserWithToken._id.toString()) { dbUserWithToken.refreshToken = [...updatedTokenArr] await dbUserWithToken.save() return res.status(401).json(authTokenErr) } const renewedRefreshToken = dbUserWithToken.generateRefreshToken(); const renewedAccessToken = dbUserWithToken.generateAccessToken(); dbUserWithToken.refreshToken = [...updatedTokenArr, renewedRefreshToken] await dbUserWithToken.save() - 第二优先级:排查Mongoose Model重复编译问题
代码中每次请求都会执行defineModel('global', 'User', userSchema),如果该方法内部没有做全局缓存,每次调用都重新编译生成新的Mongoose Model,不同Model实例持有的版本键配置、内部状态不互通,也会触发版本校验失败。
可以加日志验证:每次调用defineModel后打印Model实例,确认User模型是全局单例,没有重复编译。 - 第三优先级:验证旧实例版本不匹配问题
如果上述修改完成后仍报错,可以在保存前重新查询最新版本的用户文档,再做修改保存,绕开旧实例版本不一致的问题:
如果改完后报错消失,可以100%确认问题根源是之前持有的文档实例版本和数据库最新版本不匹配。// 替换原有第61行的save逻辑 const latestUser = await User.findById(dbUserWithToken._id).exec() latestUser.refreshToken = [...updatedTokenArr, renewedRefreshToken] await latestUser.save()
额外注意:第11行的
res.clearCookie参数传法有误,该方法的签名是res.clearCookie( cookieName, [options] ),当前代码把prevRefreshToken作为第二个参数传入,会导致Cookie无法正常清除,建议一并修正。
内容的提问来源于stack exchange,提问作者Paeon
相关产品推荐
相关产品推荐

