尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Cookie与JWT全解析:从登录状态原理到安全攻防实战

Cookie与JWT全解析:从登录状态原理到安全攻防实战 做Web开发基本没人能绕开一个问题——用户的登录状态怎么保存。网上关于Cookie和JWT的讨论一搜一大把但大部分内容都停留在“Cookie存浏览器、JWT存客户端”“Session在服务端、JWT在客户端”这种表面区分。你拿着这种认知去做技术选型大概率会在第一个真实项目里翻车。我前两个月刚把一个老项目的Session-Cookie登录改造成Spring Security配合JWT的登录体系中途还补了一轮安全加固这次就把Cookie和JWT从底层原理、实战实现到安全漏洞完整摊开来讲清楚。1. 先把关系理清楚Cookie、Session、JWT不是同一个维度的东西1.1 从一次HTTP请求说起HTTP协议有个天然特性它是“失忆”的。每一次请求到达服务器时服务器看到的都是一张陌生的脸——它不知道这个请求带着什么上下文也不知道这个用户之前有没有登录过。于是大家才搞出了一套“凭证”机制客户端需要主动证明“我是谁”。这件事放在现实世界里其实就是进门刷门禁卡门禁系统不关心你是张三还是李四它只认你手里的卡能不能匹配数据库里的授权记录。Web里的登录状态保持本质上就是在解决“如何让失忆的HTTP协议记住你”这个悖论。1.2 绝大多数人的“区别”认知是错的我看到过太多博主把Cookie、Session、JWT三样东西放一起对比然后得出结论“Cookie不安全Session安全JWT安全”。这种对比本身就是外行话因为这三样东西根本不在同一个维度。Cookie是浏览器提供的一种本地存储载体它负责“存”。Session是服务端维护的一份会话状态它负责“记”。JWT是一种自包含的数据格式它负责“认证”。打个比方。Cookie像一个“凭证袋”浏览器帮你把各种凭证小票放进去每次请求自动递过去Session是服务端收银台的“账本”商家在账本上记录你这个客户的消费记录JWT则是一张“自带防伪印章的通行证”印章一验就知道通行证是不是真的、有没有过期服务端连账本都不用翻。所以你可以用Cookie存Session ID也可以用Cookie存JWT还可以不用Cookie直接把JWT放在Authorization头里。把它们放在对立面比较本身就是没搞懂各自的职责。1.3 为什么JWT会在前后端分离时代被大量使用传统Java Web项目用Session-Cookie是黄金搭档因为页面和服务端天然同源浏览器自动携带Cookie服务端根据sessionId去内存或数据库查状态流程非常顺。但到了前后端分离、移动端大量出现之后情况变了。API接口要被多个端调用浏览器、小程序、iOS、安卓甚至第三方开放平台。这时候依赖浏览器自动携带Cookie的机制就不好使了移动端原生应用根本没有Cookie这个概念必须手动处理。JWT的优势在这里放大了登录后服务端签发一个包含用户信息和过期时间的令牌各个端只要在请求头里带上Authorization: Bearer token服务端验签通过便放行。不需要Session存储不需要分布式Session共享天然适合微服务和多端场景。2. 两种登录态方案的全链路跑通演示2.1 Cookie-Session方案老牌的“有状态”维护方式把Cookie和Session配合起来跑一遍完整登录流程你会更清楚它们的配合逻辑。用户向服务端提交账号密码服务端验证通过之后在服务端创建一条Session记录并生成一个唯一的sessionId。真正存下来的可能是用户ID、登录时间、IP、过期时间这些信息。存储位置可以是本地内存也可以是Redis。然后服务端通过响应头返回Set-Cookie: JSESSIONIDxxxx; Path/; HttpOnly浏览器收到之后把它放进自己的Cookie仓库里。之后用户每次发请求浏览器都会自动在请求头里带上Cookie: JSESSIONIDxxxx服务端去Session存储里查找这个id对应的记录查得到就认为用户已登录查不到就返回登录过期。这个方案最大的特点是“有状态”——会话保存在服务端所以服务端可以随时把某条Session删除用户立即下线。这是JWT做不到的。分布式场景下共享Session需要考虑Session粘滞或Redis集中存储这是常见的坑。2.2 JWT方案无状态令牌的完整请求流JWT的全称是JSON Web Token。它把用户身份信息和过期时间打包成一个字符串用签名保证不可篡改然后下发给客户端。一个标准JWT由三部分组成Header头部、Payload载荷、Signature签名用点号分隔xxxxx.yyyyy.zzzzz。Header里声明令牌类型和签名算法比如{alg:HS256,typ:JWT}。Payload里放用户相关数据和过期时间比如{sub:10001,name:zhangsan,exp:1735689600}。Signature是对前两部分的签名具体逻辑是先对Header和Payload分别做Base64Url编码拼成字符串再用约定的密钥如your-256-bit-secret通过HMAC-SHA256算法算出一个签名追加在后面。登录成功后客户端拿到JWT。之后的请求在Authorization头里带上完整令牌格式是固定的GET /api/user/profile HTTP/1.1 Host: api.example.com Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDAwMSIsImV4cCI6MTczNTY4OTYwMH0.xxxx服务端拿到令牌后拆开Header和Payload再用密钥重新计算签名比对一致且没有过期就认定身份有效。全程不需要查询任何Session存储这便是“无状态”的核心含义。2.3 一张表说透两种方案的差异维度Cookie-SessionJWT状态存储位置服务端内存/Redis客户端令牌本身服务端是否保存会话是否无状态跨域支持受Cookie跨域策略限制天然支持请求头可自定义移动端支持需要手动处理Cookie直接使用Token主动吊销会话简单删Session即可难需要配合黑名单或刷新令牌分布式扩展需要Session共享方案天然友好不需要共享存储数据可见性服务端控制客户端不可见Payload是Base64编码明文可读请求体积通常只有一个短IDPayload越大请求头越大这张表里最容易被忽略的是“主动吊销”这一行。很多项目选JWT就是因为“无状态”结果后来说“我要把你的账号踢下线”发现根本做不到因为令牌在客户端手里服务端没有记录。这是所有JWT方案都必须提前想清楚的trade-off。3. 从Java Web到SPA两种方案的工程落地路径3.1 Java Web里Cookie-Session的经典实现Java Web用Cookie-Session登录很多老项目都这么写。用户体验上很顺登录成功后通过HttpSession获取session对象往里塞用户ID同时让容器自动下发JESSIONID Cookie。关键点在于如果不加约束默认的JSESSIONID是有安全问题的。我改造老项目时发现原代码只做了两件事request.getSession().setAttribute(userId, userId)然后就没有然后了。既没有设置HttpOnly也没有设置过期时间被抓包后直接把JSESSIONID一替换就能登录别人的账号。正确的做法是主动设置会话Cookie的安全属性Cookie cookie new Cookie(SESSION, session.getId()); cookie.setHttpOnly(true); cookie.setSecure(true); cookie.setPath(/); cookie.setMaxAge(30 * 60); // 30分钟单位秒 response.addCookie(cookie);HttpOnly告诉浏览器这个Cookie不允许被JavaScript读取能挡掉大部分XSS窃取Cookie的攻击Secure保证只在HTTPS连接上传送Path限定作用范围MaxAge控制存活时间。服务端读取端也要检查Session是否超时并在登录成功或密码修改后主动session.invalidate()防止会话固定攻击。3.2 Spring Security整合JWT的关键配置项目改成前后端分离之后原来的Session方案没法满足多端需求我改用Spring Security加JWT。整合逻辑并不复杂核心就三块过滤器、令牌工具类、适配后的Security配置。先写一个JwtUtil工具类负责生成和解析令牌public class JwtUtil { // 实际项目中应该放到配置中心而不是写死在代码里 private static final String SECRET your-256-bit-secret; private static final long EXPIRE 30L * 60 * 1000; // 30分钟 public static String generateToken(String userId, String username) { return Jwts.builder() .setSubject(userId) .claim(username, username) .setExpiration(new Date(System.currentTimeMillis() EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }然后写一个过滤器在Spring Security的过滤器链中插入JWT校验逻辑public class JwtAuthenticationFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { String token header.substring(7); try { Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.getSubject()); request.setAttribute(username, claims.get(username)); } catch (JwtException e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return; } } chain.doFilter(request, response); } }最后在Security配置里注册这个过滤器并关掉默认的Session登录、CSRF防护因为认证信息已经不再走Cookie了http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/api/auth/**).permitAll() .anyRequest().authenticated() .and() .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);需要注意的是JWT验证时需要做好异常分类令牌过期、签名错误、令牌格式非法应该返回不同的错误码而不是统一401否则前端根本分不清是登录过期还是被篡改。3.3 前后端分离SPA项目的JWT实践前端项目接入JWT的坑主要在“令牌放哪里”。放在localStorage里简单但XSS攻击一旦得手就全盘泄露放在Cookie里稍微安全一点可以配合HttpOnly但就要处理Cookie跨域和CSRF问题。个人实践是短期的access token放内存变量刷新页面后用刷新令牌重新获取如果项目对安全性要求没那么高放localStorage但配合严格的CSP策略也能用。前端还需要在axios或fetch的拦截器里统一加上Authorization头并在收到401时跳转到登录页。4. 令牌过期与续签无状态方案的生死线4.1 为什么过期时间是绕不开的JWT签发出去之后服务端没有留存任何记录无法主动让令牌失效。如果令牌永不过期一旦泄露就等同于把账号拱手送给攻击者之后做任何安全加固都白搭。所以过期时间是JWT设计的底线。但设置多长是个问题。设太短用户老被踢下线体验糟糕设太长泄露窗口变大安全风险高。行业里常见的做法是15分钟到2小时之间具体根据业务类型权衡。金融类系统尽量短资讯类系统可以长一些。4.2 滑动续期的工程实现一种常见的续期方案是“滑动续期”用户的令牌已经验证通过了如果剩余有效期不足一定比例就签发一个新令牌返回给前端。实现逻辑不复杂在过滤器校验token时取出exp跟当前时间对比如果剩余时间小于总有效期的1/3就在响应头里写入新token。前端在响应拦截器里捕获这个头替换本地保存的token。long expTime claims.getExpiration().getTime(); long now System.currentTimeMillis(); long expireInterval expTime - now; long totalValidity 30L * 60 * 1000; if (expireInterval totalValidity / 3) { String newToken JwtUtil.generateToken(claims.getSubject(), (String) claims.get(username)); response.setHeader(X-New-Token, newToken); }优点是完全基于无状态模型不用额外存储缺点是令牌实际没有“延长”多少而且服务器重启后判断逻辑必须在网关或统一入口实现否则每个服务各生各的前端会被搞晕。4.3 刷新令牌与Redis失活方案更严谨的做法是引入Refresh Token机制。登录时返回两个令牌短期access token15分钟用于业务接口长期refresh token7天只用于换取新的access token。access token过期后前端持refresh token调用刷新接口换取新令牌refresh token过期强制重新登录。Refresh Token本身还是要考虑吊销问题我的做法是把refresh token的哈希存在Redis里过期时间跟token一致。用户主动注销时直接删除Redis中的哈希即使refresh token还在客户端手里也无法换取新令牌。这样既保留了JWT无状态校验的轻量又弥补了无法主动吊销的短板。4.4 黑名单机制与jti字段另一个兜底方案是用Redis做“黑名单”。在服务端生成JWT时在Payload里加一个jtiJWT ID字段值是UUID。需要吊销某个令牌时把它的jti写入Redis黑名单剩余有效期是多少就设置多长的TTL。每次请求校验JWT时先查一下jti是否在黑名单里。这个方案保留了JWT的无状态校验只在“需要吊销”的极少数情况下查询Redis整体性能损耗很小适合“用户改密码后所有旧令牌失效”之类的场景。5. 安全攻防视角Cookie伪造与JWT漏洞的根源5.1 Cookie伪造的套路与防护Cookie伪造的核心思路是客户端提交的Cookie内容是可控的服务端如果信任了这些内容且没有做完整性校验身份边界就被击穿了。最常见的攻击场景有两种。一是服务端只通过Cookie里的某个字段比如userId判断身份没有校验签名攻击者改一下值就变成了别人。二是会话固定攻击攻击者提前拿到一个未认证的sessionId诱导受害者使用这个sessionId登录登录成功后攻击者复用同一个sessionId进入受害者账号。防护手段其实不复杂服务端给Cookie加签名或加密登录成功后强制轮换sessionId设置HttpOnly、Secure、SameSite属性敏感操作重新要求输入密码。实际上各大厂做登录Cookie也是如此思路抓包看似能拿到Cookie字段但伪造一个合法签名几乎不现实。5.2 JWT的几大漏洞与默认密钥教训JWT本身只是一个协议漏洞往往不是出在协议上而是出在使用它的方式上。algnone攻击如果把Header里的算法改成none且服务端校验不严格JWT就没有签名保护攻击者随便改Payload都不会被察觉。算法混淆攻击服务端公钥是公开的如果允许客户端指定算法攻击者把RS256改成HS256然后用公钥作为HMAC密钥去签名服务端拿公钥验签就通过了。弱密钥/默认密钥签名密钥如果是弱口令或者默认值攻击者可以直接暴力破解出密钥伪造任意令牌。Payload明文泄露JWT的Payload只是Base64Url编码不是加密放进去的手机号、邮箱全是明文可见。有人以为JWT是加密的这是严重的误解。过期时间不校验部分代码只验签不校验exp过期令牌照样通行。这里重点说一下默认密钥问题。国内有个很典型的案例是Nacos的nacos默认密钥身份认证绕过漏洞CNVD-2023-17316。Nacos在版本迭代中内部使用了JWT做身份认证但默认密钥是固定的、公开的攻击者拿到这个默认密钥后可以自行签发JWT令牌直接绕过管理后台登录。后来官方要求用户强制修改默认密钥才修复。这个案例给所有人的教训是JWT密钥必须随机生成、独立管理、定期轮换绝不能使用框架自带的默认值更不能写死在代码仓库里。5.3 调试与安全测试中的实用工具操作日常开发和安全自测中我用的比较多的是Burp Suite和Reqable这两个工具。Burp Suite改Cookie做安全测试很方便。用Proxy拦截住请求右键发送到Repeater在请求头里直接修改Cookie: JSESSIONIDxxx可以快速验证服务端是否严格校验会话如果项目用的是JWT在Repeater里把Authorization头的token换一个伪造值看看服务端返回什么状态码能很快判断异常处理是否完善。Reqable在接口调试中对Cookie的处理做得很顺手。在接口A的响应里返回了Set-Cookie接口B需要带上这个Cookie才能访问手动复制粘贴很啰嗦。Reqable可以在接口列表中设置变量提取规则把响应里的Cookie值提取到一个变量然后在接口B的环境变量或Auth配置里引用这个变量实现“响应Cookie自动代入下一个接口”省去不少重复劳动。安全测试只是手段核心还是验证一件事服务端对所有非法凭证都必须给出明确拒绝。401、403、还有业务统一的错误响应码都要经得起篡改测试才算过关。结尾我个人的体会是Cookie和JWT没有绝对的谁优谁劣关键是你想清楚项目到底需要“有状态”还是“无状态”的登录体系。传统同源Web应用用Session-Cookie依然稳定可靠前后端分离、多端接入、分布式部署的场景JWT配合刷新令牌和短过期时间是最稳的组合。别盲从“JWT更安全”这种说法安全是设计出来的不是选型选出来的。最后分享一个建议无论选哪种方案把过期时间、续签机制、主动吊销和密钥管理这四件事提前设计好比纠结用哪个方案重要得多。
返回列表