资讯详情

资讯详情

Node.js用户身份验证实战:Session、JWT与密码存储选型

“登录成功了切到别的标签页喝了口水回来一刷新页面又变成未登录状态。”这句话大概是每个从写页面转向写接口的开发者都会遇到的一道坎。前面七十多课我们都在和 DOM、事件、请求打交道等到自己动手用 Node.js 起一个服务把注册登录写出来才发现真正麻烦的不是把表单提交上去而是怎么让服务端稳定地记住你是谁。Node.js 用户身份验证这件事说到底就三个问题密码怎么存、身份怎么带、过期怎么办。这三个问题每一个都有好几种解法每一种解法背后都藏着一批人在生产环境里踩出来的坑。这篇内容面向的是已经能写基本 Express 路由、但对鉴权还停留在抄一段代码能跑就行阶段的朋友也适合那些已经用上了 JWT、但说不清为什么这么设计的人。我会把会话和令牌两条路线都拆开讲透附上可以直接跑起来的代码以及那些文档里基本不会写的参数取舍和排查经验。1. 先把问题想清楚身份验证到底在验证什么动手写代码之前得先弄明白 HTTP 这套东西的先天设定。HTTP 是无状态的服务端处理完一个请求就把你忘了下一个请求过来它压根不认得你。所谓登录状态本质上是服务端或者客户端额外维护的一份映射关系——要么服务端存一张表键是某个随机串值是你的用户 ID要么客户端拿着一个服务端签过名的凭证每次请求带上来服务端验签即可。前者叫会话Session后者叫令牌Token。理解了这一点后面所有的方案选择、参数配置、坑点排查都能归到这份映射关系存在哪、怎么带、什么时候失效这三个维度上。1.1 一个典型故障刷新就掉线背后的三个断点我刚接触这块的时候写出来的第一个登录接口是这样的用户 POST 用户名密码服务端校验通过后返回一个{ code: 0, user: {...} }前端把这个对象往 localStorage 一塞跳转到首页。首页拿到 localStorage 里的用户信息就渲染。看起来挺顺但只要用户手动清一下缓存、或者换台设备就得重新登录。更别扭的是所有需要权限的接口其实根本没做校验任何人直接调接口都能拿到数据——前端藏起来的那点东西在浏览器地址栏敲一下接口路径就全暴露了。后来改成了在服务端用一个全局对象const sessions {}存登录态登录时生成一个随机 ID 写进 Cookie。这次刷新不掉线了但新问题接踵而至本地用 nodemon 一改代码自动重启sessions这个对象跟着进程一起没了所有人被踢下线上线之后开了两个进程做负载均衡用户在 A 进程登录请求打到 B 进程照样是未登录再往后用户量上来内存里堆的会话对象越来越多连个过期清理都没有。把这段经历拆开看其实是三个独立的断点。第一是状态存储的位置选错了进程内存天然不适合存需要跨请求、跨进程存活的数据。第二是凭证的传递链路断了前端要么没带、要么带了但服务端没读。第三是失效策略缺失会话永不过期令牌永不过期等于给自己埋了一颗随时会响的雷。这三个断点不是某一套方案独有的换成 JWT 一样会以别的形式冒出来。所以选型的时候与其纠结哪个更先进不如拿这三个断点去套看哪套方案在你当前的部署形态下代价最小。1.2 三套方案的对照会话、令牌、以及中间的混合路线市面上常见的做法大致三类我把它们放在一张表里对照你在自己项目里对号入座就行。维度Cookie Session纯 JWT双 Token 混合状态存放服务端Redis/数据库客户端服务端无状态短期令牌无状态长期令牌落库水平扩展需要共享存储天然支持短期天然支持刷新需共享存储主动踢人下线删 Key 即可立刻生效做不到只能等过期删刷新令牌最长等 access 过期单次请求开销一次 Redis 查询一次签名验算几乎无 IO同左跨域携带依赖 Cookie 策略较麻烦放 Header最灵活同左主要风险面CSRFXSS 窃取、令牌体积两者兼顾实现复杂度高适合场景传统多页应用、后台管理前后端分离、多端复用中大型前后端分离项目选型的判断标准其实很朴素。如果你的项目是给内部用的后台系统用户量可控、部署在同一套域名下那 Cookie Session 是最省心的因为立刻踢人下线这个能力在很多业务里是刚需而 JWT 天生给不了。如果你要做的是给多个客户端Web、小程序、桌面端共用的开放接口令牌放 Header 里显然比让每个客户端去处理 Cookie 更现实。如果两边都想要那就走双 Token短期的 access token 保持无状态长期的 refresh token 落库以保留撤销能力用一点复杂度换两边的优点。我个人的建议是第一次做鉴权别一上来就冲着最复杂的方案去。先把会话方案完整跑通一遍你会对凭证怎么带、中间件怎么拦、过期怎么处理建立肌肉记忆这些认知在换到 JWT 时一样用得上。等你能说清楚自己项目为什么需要无状态、能接受多长的失效延迟再迁也不迟。2. 密码存储动手前必须先做对的一件事鉴权系统里唯一不能出错的地方就是密码存储。别的地方写错了顶多是体验问题密码存错了就是安全事故。而且这个错误几乎不可逆——一旦数据库里的密码以某种弱形式泄露你没法召回只能通知所有用户改密码。2.1 从明文到加盐哈希为什么普通摘要算法不够用最原始的做法是明文存储现在应该没人这么干了但稍微好一点的做法反而更危险那就是直接对密码做一次 MD5 或 SHA-256。很多人觉得都加密了还不安全吗问题在于哈希算法被设计出来的目标恰恰是快。一块普通的显卡每秒能算几十亿次 MD5把常见密码字典跑一遍也就几分钟的事。更要命的是不加盐的哈希存在彩虹表问题同一个密码永远得到同一个摘要攻击者可以预先算好一张巨大的反查表拿到摘要直接查明文甚至连算都不用算。加盐解决的是相同密码产生相同结果这个问题。给每个用户的密码配一段随机字符串拼在一起再做哈希这样即使两个用户密码完全一样存进数据库的也是两个不同的值彩虹表直接失效。但仅有盐还不够因为攻击者拿到盐之后依然可以用 GPU 高速爆破单个账号。所以真正需要的是一种故意慢的算法让单次计算的成本高到暴力破解在经济上不划算。bcrypt、scrypt、argon2 都属于这一类它们内部都有一个可以调的工作量参数。这里有个容易忽略的细节bcrypt 的输入会被截断到 72 字节。如果你的用户密码允许很长或者允许中文UTF-8 下一个汉字 3 字节超出部分相当于没参与计算。前后端做密码长度校验时把上限卡在 64 字符以内是个稳妥的选择既够用又不会触发截断。2.2 bcrypt 的 cost 参数怎么定一次实打实的算术bcrypt 的哈希串长这样$2b$10$N9qo8uLOickgx2ZMRZoMye...。中间那个10就是 cost 因子代表内部迭代2^10 1024轮。cost 每加 1计算时间翻倍。这个参数的取舍完全是个工程问题得算账。我本机用 Node 跑过一次基准测试单核环境下 cost10 大约 60 到 90 毫秒cost11 翻到 130 到 180 毫秒cost12 就到 260 到 350 毫秒了。这个数字看着不起眼但它是同步占用 CPU 的——bcrypt 的异步版本会把计算丢进线程池不阻塞事件循环但线程池默认只有 4 个线程。假设你的登录接口峰值 QPS 是 50cost 取 12平均耗时按 300 毫秒算需要的并发处理能力就是50 × 0.3 15个并行计算单元。线程池只有 4 个意味着高峰时会有大量请求排队等待登录接口的响应时间会被拉到一秒以上。这时候正确的做法不是无脑降 cost而是先想清楚登录接口本身就该限流正常人不会一秒登录五十次真有这个量级那是业务在增长该加机器或者在前面挡一层专门的认证服务。我的实际取值习惯是面向普通用户的站点用 10内部系统用 8 到 9涉及资金或敏感数据的用 12。判断依据是单次哈希耗时落在 100 毫秒上下——快于这个值安全性不足慢于这个值用户体验开始明显变差。还有一点cost 是可以随业务增长调整的用户下次登录时用新 cost 重新算一遍再存回去就行不需要强制所有人改密码。2.3 注册与登录接口的完整实现先装依赖npm i express bcrypt ioredis然后写用户服务。// services/userService.js const bcrypt require(bcrypt); const SALT_ROUNDS 10; // 注册只存哈希绝不存明文 async function register({ username, password }) { if (!username || !password) throw new Error(INVALID_PARAM); if (password.length 8 || password.length 64) throw new Error(WEAK_PASSWORD); const passwordHash await bcrypt.hash(password, SALT_ROUNDS); // 这里用伪代码示意持久化实际换成你的 ORM const user await db.user.create({ data: { username, passwordHash, createdAt: new Date() }, }); return { id: user.id, username: user.username }; } // 登录无论用户是否存在都走一次比对避免时序泄露 const DUMMY_HASH $2b$10$abcdefghijklmnopqrstuv; async function verifyLogin({ username, password }) { const user await db.user.findUnique({ where: { username } }); const hash user ? user.passwordHash : DUMMY_HASH; const matched await bcrypt.compare(password, hash); if (!user || !matched) { // 统一错误文案不要区分用户不存在和密码错误 throw new Error(INVALID_CREDENTIALS); } return { id: user.id, username: user.username }; }这段代码里有几个地方是刻意的。第一用户不存在时也要跑一次bcrypt.compare比对的目标是一个固定的假哈希。因为 bcrypt 本身耗时几百毫秒如果用户不存在就直接返回攻击者可以通过响应时间差判断出哪些用户名是真实存在的这就是所谓的时序攻击。第二登录失败的提示统一成用户名或密码错误不要给攻击者做用户枚举的便利。第三注册时同时校验长度上下限上限 64 就是前面提到的 72 字节截断问题。注意bcrypt.hash里传的SALT_ROUNDS是 cost 而不是盐本身盐是库内部随机生成并直接拼进最终哈希串的。所以数据库里只需要一个passwordHash字段不需要额外的 salt 列。很多人第一次看文档时会误以为要自己生成盐。还有一个实践细节值得提。真实的注册流程里用户提交的密码建议先用crypto.createHash(sha256).update(password).digest(base64)处理一遍再交给 bcrypt。这样做的好处是把任意长度的输入压成固定长度彻底规避 72 字节截断同时不损失任何熵。代价是哈希串格式和标准 bcrypt不兼容如果以后要迁移到别的语言实现会麻烦一点。做不做这一步取决于你的项目规模和后续规划小项目直接截断校验就够了我一般倾向于做因为迁移的成本远低于事后再改数据。3. Cookie Session最经典也最容易踩坑的一条路会话方案的核心思路简单到一句话能说完登录成功后生成一个足够随机的字符串当钥匙服务端存一份钥匙 → 用户 ID的映射浏览器存这把钥匙之后每次请求自动带上服务端查表认人。简单归简单真正落到生产环境存储选型、Cookie 属性、会话固定攻击这三块每一个都能让新手折腾半天。3.1 会话存内存还是存 Redis先算清楚丢了会怎样Express 生态里最常用的中间件是express-session但它默认的MemoryStore是明确标注不适合生产环境的。原因很直白数据存在进程内存里进程一重启全丢开多进程做负载均衡时各个进程的数据互不可见而且它不会主动清理过期的会话跑久了内存只涨不降。替代方案有两个方向。一是存到 Redis键类似sess:sessionId值是序列化后的用户信息设置 TTL 自动过期。这是绝大多数项目的选择因为 Redis 读写快、支持过期、多个应用实例共享同一份数据扩容时也不用做任何数据迁移。二是干脆不用express-session自己写一个极简的实现用签名 Cookie 直接携带加密后的用户 ID——这就是cookie-session的思路服务端完全无状态但代价是没法主动让某个会话失效。Redis 方案的配置大概长这样// app.js const session require(express-session); const RedisStore require(connect-redis).default; const { createClient } require(redis); const redisClient createClient({ url: redis://127.0.0.1:6379 }); redisClient.connect().catch(console.error); app.set(trust proxy, 1); // 部署在反向代理后面时必须开否则拿不到真实 IP 和协议 app.use(session({ store: new RedisStore({ client: redisClient, prefix: sess: }), name: sid, // 别用默认的 connect.sid会暴露技术栈 secret: process.env.SESSION_SECRET, // 从环境变量读不要写死在代码里 resave: false, // 没有修改就不重写减少 Redis 写入 saveUninitialized: false, // 未登录的访客不创建会话省存储 rolling: true, // 每次请求顺延过期时间活跃用户不掉线 cookie: { httpOnly: true, secure: process.env.NODE_ENV production, sameSite: lax, maxAge: 1000 * 60 * 60 * 24 * 7, // 7 天 path: /, }, }));resave: false和saveUninitialized: false这两个参数必须显式设置。默认值在新版本里虽然已经不推荐但老项目里经常能看到没配的情况结果就是每个爬虫请求都会在 Redis 里留下一条空会话几天下来能堆出几百万个无效键。rolling: true是个体验优化用户只要还在操作过期时间就往后顺延避免写着写着东西突然被踢出去。3.2 Cookie 的几个属性每一个都对应一类攻击Cookie 的属性看着零碎其实每一条都对应着一类具体的风险理解了这个对应关系就不容易配错。HttpOnly的作用是让 JavaScript 读不到这个 Cookie。如果站点存在 XSS 漏洞攻击者注入的脚本可以执行document.cookie把凭证抠走加上HttpOnly之后这条路就断了。所有存放凭证的 Cookie 都必须开这个属性没有例外。需要注意它挡的只是读取如果 XSS 已经能执行任意脚本攻击者依然可以直接用fetch发请求浏览器会自动带上 Cookie所以HttpOnly不能替代 XSS 防护。Secure限制 Cookie 只在 HTTPS 连接上发送防止明文网络中被截获。本地开发环境是 http所以这个属性要按环境区分。这里有个常见的坑如果 Node 服务部署在 Nginx 或负载均衡后面req.secure判断的其实是 Node 到代理这一段永远是 false导致secure: true的 Cookie 根本种不下去。解决办法就是上面代码里的app.set(trust proxy, 1)。SameSite是这几年最重要的一项。历史上浏览器的默认行为是任何跨站请求都带上 Cookie这直接导致了 CSRF你在 A 站登录着访问了恶意页面 BB 页面里藏一个自动提交的表单往 A 站发转账请求浏览器乖乖带上你的 Cookie服务端看起来就像是你本人操作的。SameSiteLax的规则是顶级导航的 GET 请求可以带 Cookie跨站的 POST、PUT、DELETE 一律不带这一条就能挡掉绝大多数 CSRF 攻击。Strict更狠任何跨站场景都不带代价是从外部链接点进来时会显示未登录体验上有点割裂。注意SameSiteNone必须同时配secure: true否则浏览器直接拒收这个 Cookie。这在前后端分域名部署比如前端在 8080、后端在 3000的本地开发场景里特别容易撞上很多人调了半天跨域登录不生效根源就在这儿。maxAge和expires决定持久化时间。不设置的话就是会话 Cookie浏览器一关就没了。设置之后要注意它是浏览器的过期时间和服务端 Session 的 TTL 是两回事两边最好保持接近不然会出现浏览器还存着 Cookie但服务端会话已经过期的尴尬状态——用户看起来是登录的一操作就报 401 然后被迫重新登录。path/也别漏默认的 path 是当前路由路径如果登录接口挂在/api/auth/login种出来的 Cookie 路径就是/api/auth其他路径的请求根本带不上。3.3 中间件保护路由以及会话固定攻击会话方案里鉴权逻辑应该收敛到一个中间件里不要让每个路由自己去判断。// middleware/requireAuth.js function requireAuth(options {}) { return (req, res, next) { if (!req.session || !req.session.userId) { return res.status(401).json({ code: UNAUTHORIZED, message: 请先登录 }); } // 会话里存的是最小信息权限等每次都从数据库或缓存读保证及时生效 req.userId req.session.userId; next(); }; } app.get(/api/orders, requireAuth(), async (req, res) { const orders await db.order.findMany({ where: { userId: req.userId } }); res.json({ data: orders }); });这里有个设计原则值得单独说会话里只存用户 ID不要存角色和权限。我见过不少项目把req.session.role admin存在会话里结果管理员被降级之后只要会话没过期他依然拥有管理员权限。权限每次从数据库或缓存里读多一次查询换来的是变更即时生效。登录接口本身还要做一件事——重新生成会话 ID。这就是防御会话固定攻击。攻击思路是这样的攻击者先去站点拿一个会话 ID然后通过各种手段让受害者的浏览器使用这个 ID比如在链接里塞参数、或者利用子域名的 Cookie 写入受害者在这个会话下完成登录后服务端把用户 ID 绑到了这个已知的会话 ID 上攻击者拿着同一个 ID 就成了登录状态。防范办法很简单登录成功时调用req.session.regenerate()换一个新 ID攻击者手里的旧 ID 就失效了。// routes/auth.js app.post(/api/login, async (req, res, next) { const { username, password } req.body; const user await verifyLogin({ username, password }); req.session.regenerate((err) { if (err) return next(err); req.session.userId user.id; req.session.save((err2) { if (err2) return next(err2); res.json({ code: OK, data: { id: user.id, username: user.username } }); }); }); }); app.post(/api/logout, (req, res, next) { req.session.destroy((err) { if (err) return next(err); res.clearCookie(sid, { path: / }); res.json({ code: OK }); }); });regenerate和destroy都是异步回调形式必须等它们执行完再响应否则前端可能拿到 200 但会话还没真正写入 Redis紧接着的下一个请求就会失败。这种偶发性的登录不生效很难复现我自己就排查过一次最后定位到是没等save回调。4. JWT无状态的诱惑与代价会话方案最让人别扭的地方是每台机器都得能访问同一份会话存储。JWT 把这个问题反过来了服务端不存任何东西所有信息都编码在令牌里只要签名能验通过就认。加机器不用同步数据跨域不用处理 Cookie多个客户端共用一套接口也不用各自适配。听起来很美但服务端不存任何东西这句话的另一面是——服务端也失去了一切控制权。4.1 三段结构拆开看头、载荷、签名一个 JWT 长这样eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.abc123...中间用两个点分成三段。第一段是头部声明用哪种签名算法第二段是载荷放你要携带的业务数据第三段是签名用密钥对前两段做运算的结果。关键认知前两段只是 Base64URL 编码不是加密。任何人拿到令牌随便找个在线工具或者写两行代码就能解出载荷内容。所以载荷里绝对不能放密码、身份证号、手机号这类敏感信息只放用户 ID、角色名这种被人看到也无所谓的东西。签名保证的是内容没被篡改不是内容看不见。验签的原理也值得理解一下。以 HS256 为例服务端拿到令牌后用同一个密钥对头.载荷再做一次 HMAC-SHA256把结果和第三段比对。因为攻击者不知道密钥他改了载荷就算不出对应的签名所以篡改必然失败。这也是为什么密钥必须足够长、足够随机且绝对不能写进前端代码或者提交到代码仓库。不依赖任何库手写一遍签发和校验能帮你彻底搞清楚每一步在做什么// utils/jwt.js const crypto require(crypto); function base64url(input) { return Buffer.from(input).toString(base64) .replace(//g, ).replace(/\/g, -).replace(/\//g, _); } function sign(payload, secret, expiresInSec 900) { const header { alg: HS256, typ: JWT }; const now Math.floor(Date.now() / 1000); const body { ...payload, iat: now, exp: now expiresInSec }; const h base64url(JSON.stringify(header)); const p base64url(JSON.stringify(body)); const signature crypto.createHmac(sha256, secret).update(${h}.${p}).digest(base64) .replace(//g, ).replace(/\/g, -).replace(/\//g, _); return ${h}.${p}.${signature}; } function verify(token, secret) { const parts token.split(.); if (parts.length ! 3) throw new Error(MALFORMED); const [h, p, s] parts; const expected crypto.createHmac(sha256, secret).update(${h}.${p}).digest(base64) .replace(//g, ).replace(/\/g, -).replace(/\//g, _); // 用定长比较避免时序攻击 const a Buffer.from(s); const b Buffer.from(expected); if (a.length ! b.length || !crypto.timingSafeEqual(a, b)) { throw new Error(INVALID_SIGNATURE); } const payload JSON.parse(Buffer.from(p, base64).toString(utf8)); const now Math.floor(Date.now() / 1000); if (payload.exp now payload.exp) throw new Error(EXPIRED); if (payload.nbf now payload.nbf) throw new Error(NOT_YET_VALID); return payload; }真实项目里当然用jsonwebtoken库更省事但知道底层在干什么排查问题时会快很多。比如签名明明对得上却报错八成是时间戳单位搞错了——exp是秒级时间戳不是毫秒写成Date.now() 3600000会得到一个几十万年后的过期时间相当于永不过期。4.2 校验时必须检查的四件事少一件都是漏洞用jsonwebtoken的时候jwt.verify(token, secret)这行代码本身只做了签名校验和过期校验。剩下几个检查项需要自己补而且每一条都对应着真实发生过的攻击。第一条显式指定允许的算法。jwt.verify如果不传algorithms参数历史上出现过算法混淆攻击攻击者把头部改成{alg:none}某些旧版本的实现会直接跳过验签或者服务端用的是 RS256 非对称签名攻击者把算法改成 HS256然后用公钥公钥是公开的去算签名服务端如果没限制算法就会验通过。正确写法是jwt.verify(token, secret, { algorithms: [HS256] })把算法白名单锁死。第二条校验 iss 和 aud。如果你的密钥在多个系统间复用这本身就该避免一个系统签发的令牌可能被另一个系统接受。加上issuer和audience校验就能隔离。第三条不要只靠 exp。exp 只是一个时间戳服务端无状态意味着令牌签发之后除非过期没有任何办法让它失效。用户改了密码、账号被盗、被管理员封禁旧令牌在有效期内依然畅通无阻。第四条载荷里的字段不能直接信。有些人为了省一次数据库查询把角色写进 JWT 然后直接用payload.role判断权限。这个做法在令牌有效期内是可行的但一旦给某个用户降权他手里的旧令牌还是旧角色。涉及敏感操作的接口该查库还是得查。const jwt require(jsonwebtoken); function verifyAccessToken(token) { return jwt.verify(token, process.env.JWT_SECRET, { algorithms: [HS256], issuer: my-app, audience: web-client, clockTolerance: 5, // 容忍 5 秒的服务器时钟偏差 }); }clockTolerance这个参数在多机器部署时很有用。不同机器的系统时间可能有几秒偏差用户在某台机器上刚拿到的令牌到另一台机器上判断成尚未生效就会莫名其妙地报错。给个几秒的容忍度能省掉不少玄学问题。4.3 双 Token 刷新机制把撤销能力找回来访问令牌有效期必须短因为一旦泄露攻击者能用的就是它的剩余寿命。但如果只设 15 分钟用户每刻钟就得重新登录一次体验无法接受。解法是把令牌拆成两个access token 短命15 分钟到 2 小时refresh token 长命7 到 30 天刷新令牌存服务端。刷新令牌不进 JWT 的载荷它就是一个随机字符串存在 Redis 或数据库里键是令牌本身值是用户 ID 和过期时间。刷新流程是access 过期后前端拿着 refresh 调/api/refresh服务端查表确认有效签发一个新的 access 返回。注销时把 refresh 从存储里删掉撤销就生效了——虽然已经发出去的 access 还能撑到过期但最长也就撑那十几分钟。// routes/token.js const crypto require(crypto); const ACCESS_TTL 900; // 15 分钟 const REFRESH_TTL 60 * 60 * 24 * 14; // 14 天 async function issueTokens(userId) { const accessToken sign({ sub: userId }, process.env.JWT_SECRET, ACCESS_TTL); const refreshToken crypto.randomBytes(32).toString(hex); await redis.set(rt:${refreshToken}, String(userId), { EX: REFRESH_TTL }); return { accessToken, refreshToken, expiresIn: ACCESS_TTL }; } app.post(/api/refresh, async (req, res) { const { refreshToken } req.body; if (!refreshToken) return res.status(401).json({ code: NO_REFRESH }); const userId await redis.get(rt:${refreshToken}); if (!userId) return res.status(401).json({ code: REFRESH_INVALID }); // 刷新令牌轮换旧的立即作废发一个新的能发现令牌被盗用 await redis.del(rt:${refreshToken}); const tokens await issueTokens(userId); res.json({ code: OK, data: tokens }); });这里的令牌轮换是个值得加的小设计。每次刷新都用掉旧的、发一个新的。如果有天攻击者偷了 refresh token 先来刷新真正的用户下次刷新时就会发现令牌失效服务端可以顺势把这个用户的所有会话都清掉。这比被动等着令牌过期强得多。前端这边还有个必须处理的问题并发刷新。页面加载时同时发了五个请求都拿到 401五个请求各自去调刷新接口第一个成功了把旧 refresh 用掉了后面四个全失败用户直接被踢下线。解决办法是在拦截器里维护一个刷新中的 Promise后续请求挂在这个 Promise 上等着刷新完成后统一重放。这个细节不写进代码里上线后一定会遇到。// 前端请求拦截器的骨架 let refreshing null; async function request(config) { config.headers.Authorization Bearer ${getAccessToken()}; let res await fetch(config.url, config); if (res.status 401) { if (!refreshing) { refreshing doRefresh().finally(() { refreshing null; }); } await refreshing; // 所有并发请求在这里排队 config.headers.Authorization Bearer ${getAccessToken()}; res await fetch(config.url, config); // 重放原请求 } return res; }5. 上线前必须补的几道防线与排查手册鉴权代码能跑通只是起点真正决定系统稳不稳的是那些出问题时怎么办的设计。这一节我按排查场景来组织把几个反复出现的问题和加固手段列清楚。5.1 问题速查表症状、根因与处理症状常见根因处理方向登录返回 200 但下个请求就 401会话没写入就去响应Cookie path 不对前端未开withCredentials检查是否 await 了 save 回调核对 Cookie 的 path 与 domain跨域请求必须显式携带凭证本地正常部署后登录失效trust proxy未开启导致secureCookie 种不下去设置app.set(trust proxy, 1)确认反向代理透传了协议头令牌刚签发就报未生效多台机器时钟不同步校验时加clockTolerance同时接入统一的时间同步服务用户越权访问到别人的数据只校验了登录态没校验资源归属每个涉及资源的查询都带上userId条件不要只靠前端传的 ID频繁被刷登录接口无限流、无失败次数控制按 IP 和账号双维度限流连续失败若干次后加验证码或临时锁定令牌体积过大导致请求头超限把权限列表、用户信息全塞进 JWT载荷只留sub和必要标识其余信息按需查缓存这张表里最值得展开的是越权访问因为它不报错、不告警静悄悄地就把数据泄露了。经典场景是订单详情接口GET /api/orders/:id中间件验完登录态就放行然后直接按 id 查库返回。攻击者把 id 改成 1、2、3 挨个试能拉到全站订单。正确的做法是把归属校验下沉到查询条件里where: { id, userId: req.userId }查不到就返回 404 而不是 403——403 反而泄露了这个资源存在但不属于你的信息。5.2 加固清单几件容易被跳过的事限流要按维度分开做。只按 IP 限流挡不住撞库因为攻击者可以用代理池换 IP只按账号限流会被用来恶意锁定他人账号。两个维度同时上再加上失败次数累计触发验证码是比较均衡的做法。日志里不能出现凭证。调试时顺手console.log(req.headers)把整个 Authorization 打出来这条日志进了集中式日志平台就等于令牌泄露。打印前统一脱敏或者干脆只记录令牌的前 8 位用于关联排查。登出必须是双向的。前端清掉本地令牌只是界面上的事服务端该删的会话、该作废的刷新令牌都得删。会话方案里destroy会话并clearCookie令牌方案里把 refresh 从 Redis 删掉。只做前端清理等于把一把还能用的钥匙扔在了路边。密码修改后清空所有会话。用户改密码往往意味着怀疑账号被盗。如果旧会话还能继续用改密码就失去了意义。可以在用户表上加一个tokenVersion字段签发令牌时带上校验时比对不一致就拒绝会话方案则遍历删除该用户的所有会话键。注意不要在 JWT 的载荷里放用户的实时权限状态也不要指望通过修改数据库里的用户信息来让已签发的令牌立即失效这两件事在无状态设计下天然做不到。要么接受一个可预期的失效延迟要么老老实实加一层状态查询。6. 我个人在几个项目里的实际取舍前后写过好几个带登录的系统从最初死磕 JWT到后来老老实实用回会话中间反复横跳了几次现在的心得比较简单先看你的部署形态和业务对立即失效的敏感度再谈技术先进性。内部后台类的项目我现在基本都用 Cookie Session Redis理由很实际用户规模就几百人一台 Redis 完全扛得住管理员要封某个账号、要强制下线删个键立刻生效不用跟业务方解释最长可能要等十几分钟代码量也少一个中间件解决全部问题。真正让我吃过亏的是 Cookie 的跨域配置前后端分开端口开发的那段时间天天在 SameSite 和 withCredentials 之间来回试后来在开发环境统一用同一个域名加路径代理这个问题就再也没出现过。面向多端提供接口的项目才会上双 Token而且一定会把刷新令牌落库。上线后遇到过一次并发刷新的问题用户在首页同时触发了七八个请求全部 401 后一起刷新结果只有一个成功其余全挂用户看到的是刚登录就被踢出来。后来在前端加了请求队列才彻底解决。这类问题在测试环境几乎发现不了因为测试时很少有人那么快地连续操作。最后分享一个排查小技巧。遇到有时能登上有的时候不行这种偶发问题别急着看代码先把服务端的时间、Redis 的 TTL、Cookie 的 maxAge、JWT 的 exp 这四个时间值打到日志里对齐一下。我碰到过的绝大多数玄学掉线根源都是这几个时间没有对齐——比如会话在 Redis 里设了 30 分钟但 Cookie 设了 7 天用户看到的是我明明还在登录状态怎么一操作就报错。把时间轴对齐之后这类问题基本一次就能定位。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →