ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JWT登录鉴权实战:从Session迁移到Token续签与安全防护

JWT登录鉴权实战:从Session迁移到Token续签与安全防护 去年接手一个前后端分离的SPA项目登录态从Session改造为JWT踩了一路坑也把很多网上说得含糊的概念彻底捋清楚了。这篇就把我对JWT的理解、实际落地过程、还有那些容易被忽略的安全问题一次性串起来讲适合正在做登录鉴权、准备从Session迁移过来、或者被JWT续签和安全问题卡住的朋友。先说一个核心判断JWT本身不复杂复杂的是它的使用边界。很多人把JWT当成加密工具其实它是签名工具很多人以为用了JWT就天然安全其实安全漏洞几乎都出在实现方式上。这篇文章会从原理出发落到真实代码和坑点尽量让新手能看懂让有经验的人也能有收获。1. 从SPA项目登录态说起为什么最终选了JWT1.1 原来用Session为什么不行了传统单体Web应用里登录状态一般靠Session维护用户登录成功后服务器在内存或Redis里存一份sessionId对应的用户数据同时把sessionId写进Cookie。浏览器每次请求自动带上Cookie服务器比对一下就知道你是谁。这套方案逻辑简单在小项目里很顺手。但前后端分离的SPA项目出现后问题就来了。前端是独立部署的静态资源API是独立部署的后端服务两者不但在不同端口甚至经常在不同域名。此时要处理CORS、跨域Cookie的SameSite属性、CSRF防护维护成本直线上升。更麻烦的是如果后端要横向扩容部署了多个实例Session默认存在单机内存里就废了——第一台机器存的登录态第二台机器不认。要么引入Redis做Session共享要么就得让负载均衡做粘滞会话两种方案都是额外的基础设施和运维成本。我当时的场景是后端有几个服务需要统一做登录鉴权前端是Vue的SPA部署在CDN上。用Session的话跨域Cookie方案要配置很多而且每个服务都要对Session存储做依赖。评估了一圈决定换成JWT把登录态直接交给客户端保存。1.2 JWT解决了什么付出了什么代价JWTJSON Web Token在这一场景下的核心优势是无状态。服务器不保存任何会话数据登录成功后签发一个自包含的Token给前端前端后续请求把Token放在HTTP Header里带过来服务器只要验签通过就信任Token里携带的用户信息。因为Token本身带着用户ID、角色、过期时间这些信息所以后端不需要查库天然适合分布式的多个服务共享一套鉴权逻辑。代价也很明确主要有三个。第一无法主动失效。Session可以随时在服务端删除JWT一旦签发在过期之前无论如何都无法让它作废除非额外引入黑名单机制那就又变成有状态了。第二体积相对膨胀。Cookie几个字节能解决的事JWT一个Token动辄几百字节放在每个请求的Header里会轻微增加带宽消耗。第三敏感信息不能直接塞进Payload。Payload只是Base64Url编码不是加密任何拿到Token的人都能直接解码看到内容。这三个代价不是用来劝退的而是提醒你JWT适合什么场景不适合什么场景。适合的是无状态、分布式、Token只包含非敏感信息、允许短时有效的场景不适合的是一次签发想永久有效的场景或者需要精细控制单点下线的场景。2. JWT三段式结构拆解Header、Payload、Signature到底在签什么2.1 拆开一个真实的Token一个JWT长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEwMDEsInVzZXJuYW1lIjoicGFuZWwiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MTk5OTQ4MDB9.Kc5kDIQj2zYrnf6Yq8GdYNaFz2LRLOR9pZ7b6Mxsbf0中间用两个点分成三段。用最简单的办法解码看内容——在命令行里执行echo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 | base64 -d会得到{alg:HS256,typ:JWT}这就是Header声明了签名算法和Token类型。第二段Payload也一样解码echo eyJ1aWQiOjEwMDEsInVzZXJuYW1lIjoicGFuZWwiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MTk5OTQ4MDB9 | base64 -d得到{uid:1001,username:panel,role:admin,exp:1719994800}这就是载荷部分承载了业务需要的用户信息以及JWT标准里规定的一些注册声明。第三段是签名它长得像乱码却是整个Token安全性的根基。2.2 签名到底保护了什么很多人第一次看到Base64解码出来的明文会惊讶这不是裸奔吗信息全都能看到。没错能看到。但JWT本来就不是加密它是签名。签名的作用是保证完整性Token在传输过程中任何一段内容被改动过签名校验就会失败。以HS256算法为例签名计算规则是HMACSHA256( base64UrlEncode(header) . base64UrlEncode(payload), secret )就是把前两段拼接起来用双方共享的密钥做一次HMAC运算把运算结果作为第三段。服务器收到Token后用同样的规则重新计算签名再和Token携带的签名做比对。一致则说明内容没被动过不一致直接拒绝。你可能会问那客户端能不能自己伪造一个Payload、然后用自己猜的密钥签名理论上如果密钥足够复杂猜不中就签不出来。密钥一共两种要么是服务器和签发方共用一个对称密钥HS256要么是一对公私钥RS256/ES256。用非对称算法时服务器只保存公钥验签签发用私钥在单独的认证服务里完成这样更安全——即使某个后端服务的公钥泄露也无法伪造Token。2.3 那些JWT规范里的注册声明除了业务自定义字段JWT规范定义了一些可选的注册声明实际使用中建议尽量带上exp过期时间Unix时间戳形式验签时必须校验这个字段网上很多教程的坑就是只验签没过期时间。iat签发时间。nbf生效时间早于这个时间点的Token应该被拒绝。aud受众说明Token给谁用防止一个Token到处通用。iss签发者标识Token是哪家签发的。jti唯一ID主要用于防重放或者黑名单记录。我在代码里最少会带exp、iat、iss、aud四个后面在中间件里校验iss和aud。很多项目里漏掉这两个校验如果Token被一个服务签发给另一个服务用一旦签发密钥泄露攻击范围会被放大很多。3. 登录到鉴权的完整实现链路签发、校验、续签3.1 登录接口验证码通过后才签发Token这是热词里提到的场景SPA项目结合验证码实现JWT登录。流程是这样的前端加载登录页请求后端获取一个图形验证码后端生成图片并把验证码答案存到Redis给前端返回一个captchaId。用户输入账号密码和验证码提交登录。后端先校验验证码用captchaId去Redis查答案比对不通过直接返回验证码错误通过则删除该验证码验证码必须一次性。验证码通过后继续校验账号密码密码存储必须是加盐哈希比如bcrypt禁止明文。校验全部通过后查询用户角色、状态等信息生成JWT返回。对应Node.js实现大致是这样const jwt require(jsonwebtoken); const crypto require(crypto); // 登录接口 async function login(req, res) { const { username, password, captchaId, captchaCode } req.body; // 1. 校验验证码一次性使用 const savedCode await redis.get(captcha:${captchaId}); if (!savedCode || savedCode.toLowerCase() ! captchaCode.toLowerCase()) { return res.status(400).json({ code: 400, message: 验证码错误 }); } await redis.del(captcha:${captchaId}); // 2. 校验用户名密码 const user await db.user.findByUsername(username); if (!user || !(await bcrypt.compare(password, user.passwordHash))) { return res.status(401).json({ code: 401, message: 用户名或密码错误 }); } // 3. 更新登录信息签发Token await db.user.updateLoginInfo(user.id, { lastLoginAt: new Date(), lastLoginIp: req.ip, }); const token jwt.sign( { uid: user.id, username: user.username, role: user.role, }, process.env.JWT_SECRET, { expiresIn: 2h, issuer: my-app, audience: my-app-web, } ); res.json({ code: 0, data: { token, user: { uid: user.id, username: user.username } } }); }这里有一个细节签发前更新用户登录信息对应了更新用户登录信息并生成返回JWT令牌这个场景。数据库操作和JWT签发在同一次请求里完成所以即使登录信息更新失败了也能及时发现不会出现Token已经发出去但登录记录没写上的不一致状态。3.2 鉴权中间件一次验签要做的事比你想的多每个需要登录才能访问的接口都会经过同一个鉴权中间件。我的实现里做了这几件事从请求头取Authorization: Bearer token没有就直接401。拆出Token后调用jwt.verify验签。验签时要同时校验issuer、audience并且库本身会校验exp。验签通过后把Token里的用户信息挂到req.user后续业务直接用。function authMiddleware(req, res, next) { const authHeader req.headers.authorization; if (!authHeader || !authHeader.startsWith(Bearer )) { return res.status(401).json({ code: 401, message: 未登录或令牌缺失 }); } const token authHeader.slice(7); try { const payload jwt.verify(token, process.env.JWT_SECRET, { issuer: my-app, audience: my-app-web, }); req.user payload; next(); } catch (err) { // 区分过期和无效方便前端做对应处理 if (err.name TokenExpiredError) { return res.status(401).json({ code: 401, subCode: TOKEN_EXPIRED, message: 登录已过期 }); } return res.status(401).json({ code: 401, subCode: TOKEN_INVALID, message: 令牌无效 }); } }如果你不想依赖第三方库也可以用Node内置的crypto模块自己写验签完整实现HS256验签不超过40行const crypto require(crypto); function base64UrlDecode(str) { return Buffer.from(str, base64url).toString(utf8); } function verifyHs256(token, secret) { const [headerB64, payloadB64, signatureB64] token.split(.); const data ${headerB64}.${payloadB64}; const expected crypto.createHmac(sha256, secret).update(data).digest(base64url); // 防止时序攻击 const actual signatureB64; const a Buffer.from(expected); const b Buffer.from(actual); if (a.length ! b.length) return null; if (!crypto.timingSafeEqual(a, b)) return null; return JSON.parse(base64UrlDecode(payloadB64)); }自己实现一遍不是为了替代成熟库而是为了彻底理解签名机制。等你亲手拼过一遍HMAC再看网上的JWT漏洞分析就轻松多了。3.3 前端如何安全地保存和使用Token前端拿到Token后存哪里是个经典话题。localStorage和sessionStorage的优点是不会被请求自动携带但XSS攻击一旦得手就能直接读走TokenCookie虽然可以加HttpOnly防止JS读取但容易踩CSRF的坑而且跨域时要处理SameSite和CORS。我的选择是纯前端应用存localStorage然后在请求拦截器里手动加到Authorization头。风险点在于必须做好XSS防护——不信任任何用户输入拼接DOM、对富文本做白名单过滤、依赖第三方库时留意供应链安全。如果项目对安全要求极高可以把Token放进HttpOnly Cookie同时用自定义Header配合CSRF Token防护这是更重但更稳的方案。一个实用的小建议后端在登录接口返回Token的同时可以顺带返回一个expiresAt时间戳前端在这个时间前几分钟主动引导用户续期或重新登录体验会比等请求401再跳登录页好很多。4. 我踩过的JWT安全坑算法混淆、KID注入和弱密钥4.1 算法混淆攻击把RS256降级成HS256这是JWT实现里非常经典的一类漏洞。原理是这样的如果服务器签发Token用RS256非对称验签时公钥是公开的攻击者把Token的Header从RS256改成HS256然后用公开的公钥内容当作HS256的对称密钥来签名。如果服务器验签时没有固定算法而是读取Header里的alg字段就会傻乎乎地用HS256和公钥去验签——结果攻击者自己签名的Token就通过了。这类漏洞的历史教训太多修复方式也很简单验签时不要相信Header里声明的算法。在jsonwebtoken库中验签时显式传入algorithms参数白名单jwt.verify(token, process.env.JWT_PUBLIC_KEY, { algorithms: [RS256], issuer: my-app, audience: my-app-web, });这样即使攻击者把Header改成alg: none或者alg: HS256验签也会失败。我建议所有人的JWT验签代码里都必须有这个白名单无论用什么语言什么库。4.2 KID注入一个参数从验签失败变成命令执行kid是JWT Header里的一个可选字段全称Key ID用来告诉服务器用哪把密钥验签。不少实现会这样写key open(f/keys/{kid}, rb).read() decoded jwt.decode(token, key, algorithms[HS256])如果kid完全来自用户输入且没有过滤攻击者把kid设置成../../../../etc/passwd服务器就会尝试读取任意文件内容当作密钥。更进一步的利用手法是把kid指向一个攻击者控制的URL或者利用某些库对文件路径的特殊处理实现代码执行。在关系型数据库中还有把KID拼接进SQL查询导致SQL注入的经典案例。正确的做法是kid只允许在白名单内取值比如一个有限集合{key1, key2}查不到直接拒绝。永远不要用用户输入拼文件路径或数据库查询。4.3 弱密钥爆破你的secret可能秒破HS256的安全性完全依赖密钥。很多人图省事把密钥设置成secret、123456、jwt-secret这种等于把门锁挂在门把手上。网上有一些公开的JWT密钥字典攻击者用一个几百MB的字典跑一遍几分钟就能爆破出密钥然后任意伪造Token。我在压测自己的项目时验证过如果密钥是secret这类弱口令用字典爆破确实秒破。换成程序生成的32字节随机数之后爆破就完全不可行了。关于密钥管理我的建议是使用crypto.randomBytes(32).toString(hex)生成至少256位的随机密钥。不同环境用不同密钥开发、测试、生产分开。密钥通过环境变量或配置中心下发不要硬编码进代码仓库。定期轮换密钥。轮换时要考虑新旧Token的兼容——我采用的方式是验签时尝试多个密钥旧的用于宽限期内的老Token。4.4 签名校验缺失手动实现时最容易犯的错有些项目没引入JWT库自己写了校验逻辑常见错误是只解Base64、只查过期时间、不验证签名。这种情况下攻击者完全不需要密钥自己改Payload再重新Base64编码就能拿到任意身份的Token。这也是我前面强调自己实现一遍没问题、但生产环境一定要用成熟库的原因。成熟库经过大量安全审计边界情况处理得完整。如果你真的需要手写记住签名验证和过期时间校验是且必须是第一步不是可选项。顺便提醒一个容易踩的坑Base64Url和标准Base64是不同的编码方式。JWT用的是Base64Url把和/替换成-和_去掉末尾的。用标准Base64库解JWT遇到特殊字符会出错或得到错误结果。加上timingSafeEqual做签名比对也是必备操作普通等值比较存在时序侧信道风险虽然实际利用难度大但技术圈对这件事早就有了共识。5. 令牌续签从固定过期到Refresh Token的取舍5.1 固定过期时间为什么让用户恼火如果Access Token有效期只有2小时用户用着用着突然401被迫重新输入账号密码体验很差。最粗暴的方案是设置超长有效期比如7天甚至30天——但Token泄露的风险窗口会成倍拉长一旦前端XSS泄露了Token攻击者可以持续数天冒用身份这在安全上不可接受。所以续签方案的核心目标很简单让登录态能平滑延续同时把泄露风险控制在更短的时间窗口内。5.2 方案一滑动续签滑动续签的逻辑不复杂在每次请求的鉴权中间件里验签成功后检查Token剩余的有效时间。如果剩余时间低于某个阈值比如总有效期的四分之一就签一个新的Token返回给前端前端下次请求自动带上新的。// 在鉴权中间件中实现滑动续签 const payload jwt.verify(token, secret, { issuer: my-app, audience: my-app-web, algorithms: [HS256], }); const THRESHOLD 15 * 60; // 剩余不足15分钟则续签 const remaining payload.exp - Math.floor(Date.now() / 1000); if (remaining THRESHOLD) { const newToken jwt.sign( { uid: payload.uid, username: payload.username, role: payload.role }, secret, { expiresIn: 2h, issuer: my-app, audience: my-app-web } ); res.setHeader(X-New-Token, newToken); } req.user payload; next();前端在响应拦截器里检测到X-New-Token就更新本地存储实现了用户无感知的续签。这个方案的优点是实现简单不引入额外的状态存储缺点是每次生成新Token都意味着原Token在理论上仍然有效如果后端服务不止一个需要确保所有服务共享同一个密钥并且新Token能被所有服务识别。另外频繁续签会产生大量Token扩散在客户端日志或者代理缓存里风险面会变大。5.3 方案二Refresh Token双Token机制更规范的方案是引入Refresh Token这个Token有效期长比如7天专门用来换取新的Access Token有效期短比如15分钟到2小时并且Refresh Token通常存储在更安全的位置使用时有更严格的校验。我落地时的设计规格是这样的项目Access TokenRefresh Token有效期15分钟~2小时7天存储位置前端内存/localStorage前端内存/HttpOnly Cookie使用场景访问业务API调用刷新接口换取新Access Token是否可撤销设计上不可撤销可在服务端记录并主动失效刷新接口的逻辑// 刷新接口 async function refresh(req, res) { const { refreshToken } req.body; // 校验Refresh Token检查是否存在/已撤销 const stored await redis.get(refresh:${req.user.uid}); if (!stored || stored ! refreshToken) { return res.status(401).json({ code: 401, message: Refresh Token无效或已过期 }); } const newAccessToken jwt.sign( { uid: req.user.uid, username: req.user.username, role: req.user.role }, process.env.JWT_SECRET, { expiresIn: 30m, issuer: my-app, audience: my-app-web } ); res.json({ code: 0, data: { accessToken: newAccessToken } }); }Refresh Token有一个显著的现实问题接口本身需要被合法调用。如果Refresh Token也放在localStorageXSS依旧能偷走如果放在HttpOnly Cookie跨域和CSRF问题又回来了。所以在SPA项目里Refresh Token往往也是存在localStorage的只是用它换Access Token的频率低暴露面稍微小一些。真正的强安全场景会引入refresh token轮换、设备绑定、异常检测等复杂策略。5.4 我的最终选择那个项目最终采用的是滑动续签原因是团队规模小、服务数量有限双Token机制的基础设施收益还没那么明显。如果今天做一个用户量大、安全要求高的产品我会直接上Refresh Token方案并且把Refresh Token存进HttpOnly Cookie刷新接口用独立域名同时做设备管理和异常登录提醒。这两种方案没有绝对优劣认清业务阶段比技术选型本身更重要。6. 一些值得长期坚持的实现习惯把项目做完之后我复盘整理了几条习惯后来每个涉及JWT的项目都在沿用第一Payload只放非敏感的用户标识和角色信息。身份证号、手机号、钱包余额这些一律不进Token需要时拿uid去查库。第二验签统一封装不要每个接口各自写一套。我在项目里把鉴权中间件做成了一层公共依赖所有服务共用同一份配置杜绝某个服务忘配algorithms白名单这种不一致问题。第三密钥轮换要演练。平时配置好两个密钥旧密钥保留一个过渡期新密钥先上线等所有服务都切换到新密钥后再在宽限期结束后移除旧密钥。听起来简单但没演练过的团队真到轮换时往往手忙脚乱。第四做好401的规范返回。给前端明确的错误码区分Token缺失、Token过期、Token无效。前端才知道什么时候该弹登录框什么时候该静默刷新什么时候属于异常情况需要上报。第五不要为了无状态而无状态。业务里需要强制下线用户比如改密码、封号、踢人时最有效的方案是维护一个用户级别的会话版本号签进Token里鉴权时对比当前版本。版本号变了说明用户被强制下线老Token全部失效。这是无状态和可撤销之间一个很好的折中。在实际开发和维护的过程中我最大的体会是JWT不是一个配置完就万事大吉的东西它更像一把钥匙设计得合理整个登录体系会很顺手设计得随意后续每一个安全补丁都在为当初的省事买单。希望这篇内容能帮你少踩一些我已经踩过的坑也让你在选择JWT时心里更有底。
返回列表