JWT身份认证实战:以苍穹外卖为例详解登录、拦截器与Token安全
做了苍穹外卖这个模拟项目之后我一直觉得整个系统里最值得拿出来聊的技术点就是JWTJSON Web Token身份认证。它的代码量不大但贯穿了登录、鉴权、会话保持、双端角色区分等一系列环节也是面试里被问得最频繁的一块。当时花了两个晚上把它从“会用”啃到“理解”说实话很多细节真的是调试了才发现。这篇文章想把我在苍穹外卖项目里对JWT应用的理解完整复述一遍包括为什么要选它、它的三段结构到底在保护什么、登录和拦截器怎么落地、以及我在实际代码里踩过的坑。如果你正在做一个前后端分离的练手项目或者准备面试时被问JWT这篇内容应该能直接帮上忙。1. 为什么外卖系统需要JWT从一个下单场景说起1.1 如果不用JWT传统Session方案会别扭在哪里先不聊JWT的概念我们回到一个很真实的场景用户打开小程序浏览店铺把一份黄焖鸡加入购物车然后提交订单。这个过程中每一次请求服务端都要知道“你是谁”。如果是传统Session方案流程是这样的用户登录成功服务端创建一份session数据存到内存里然后把sessionId通过Cookie返回给浏览器浏览器后续请求自动带上这个Cookie服务端再从内存里查出对应的用户信息。这套机制在单体应用里跑得挺好但放到苍穹外卖这种“小程序端管理后台端”同时访问的场景里就会有点别扭。第一个问题是跨域。小程序发请求不是浏览器传统的同源策略处理方式要维护Cookie需要额外配置前端处理起来也不顺手。第二个问题是状态存储。Session是要占服务端内存的登录的人一多内存也跟着涨。第三个问题是分布式扩展。如果后来项目从单台服务扩展成多台实例用户第一次请求落在A机器第二次请求落在B机器而session存在A机器的内存里这就有问题了要么做session共享要么做粘滞会话平白多了一堆运维和开发成本。对比下来JWT的思路就完全不同用户登录成功后服务端不存任何会话记录只把一个包含了用户信息的令牌签发给前端前端之后每次请求都把这个令牌放在请求头里带上服务端只需验签就能确认身份。不需要查内存不需要查数据库天然就是无状态。1.2 JWT在苍穹外卖里的定位与选择理由苍穹外卖这个项目里其实有两条业务线一条是C端小程序面向普通用户点餐另一条是B端管理后台面向商家处理菜品、订单、分类这些信息。这两种角色权限完全不同但又共用同一个后端服务。JWT刚好能把“身份信息”和“角色信息”都塞进Token里服务端解析之后不光知道“你是谁”还知道“你是哪类人”接下来能不能访问某个接口就一目了然。我选择用JWT而不是改造Session方案还有一个很实际的原因项目里前后端是完全分离的前端不在同一个域名下部署而Token通过自定义请求头传递几乎没有跨域限制。开发的时候用Postman调试也方便请求头里加上Authorization: xxx就行。对于一个给学习者练手、给面试者讲原理的模拟项目来说JWT的链路足够完整代码量也不会失控非常合适。2. JWT三段结构拆解Header、Payload、Signature在保护什么2.1 一个Token拆开看里面其实只有三块内容我第一次接触JWT时去看生成的Token长这样一串很长的字符串由两个点分成了三段。当时不理解为什么要分三段后来自己手动解析了一遍才彻底明白这三段分别是Header、Payload、Signature。Header部分是一个JSON主要声明了这个Token用的签名算法和Token类型。苍穹外卖里我用的算法是HS256所以Header大概是这样的{ alg: HS256, typ: JWT }Payload部分是真正的“干货”承载业务数据。苍穹外卖里我放入了用户ID、角色、签发时间和过期时间{ sub: 12, userId: 12, role: admin, iat: 1713000000, exp: 1713001800 }Signature部分则是把前两段用Base64URL编码后的字符串拼起来再和服务器端的密钥一起通过Header里指定的算法做一次签名运算。这个签名的意义在于Token里的内容一旦被任何人改动重新计算的签名就不会匹配服务端校验时会直接拒绝。形象点说前两段是写在明信片上的内容签名就是封口处的防伪标签。2.2 为什么HS256够用以及算法选择的底层逻辑做苍穹外卖时Token的签发和校验都在同一个后端服务里完成所以选择了HS256这种对称加密算法。它的特点是加密和解密用同一个密钥服务端持有密钥既能签发Token也能校验Token。实现简单性能好对单服务应用完全够用。如果后期把单体拆成多个微服务验证逻辑分布在不同的服务节点上再用HS256就会遇到一个问题每个需要验签的服务都要持有同一个密钥密钥一旦泄露任何持有密钥的服务都能伪造Token。这个时候更合理的方案是换成RS256这种非对称算法私钥负责签发公钥负责验签发布公钥给所有验签方即使某个验签服务被攻破也无法伪造新Token因为私钥还在签发的核心服务手里。这个属于系统架构演进到一定规模再考虑的升级方向当前阶段HS256式最简单务实的选择。2.3 签发与解析Token的核心代码苍穹外卖项目里我封装了一个JwtUtil工具类来统一处理生成和解析逻辑。基于常见的JWT库代码大致是这个样子Component public class JwtUtil { private static final String SECRET_KEY sky-take-out-secret-key; private static final long EXPIRE_TIME 60 * 30; public String generateToken(Long userId, String role) { Date now new Date(); Date expireDate new Date(now.getTime() EXPIRE_TIME * 1000); return Jwts.builder() .setHeaderParam(typ, JWT) .setSubject(String.valueOf(userId)) .claim(userId, userId) .claim(role, role) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, SECRET_KEY.getBytes(StandardCharsets.UTF_8)) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET_KEY.getBytes(StandardCharsets.UTF_8)) .parseClaimsJws(token) .getBody(); } }这里有一个操作上容易踩坑的点signWith方法传的key必须是字节数组而且要保证生成和解析时使用的字符集一致。我之前图省事直接传字符串某些版本下能跑换了依赖版本后突然解析异常排查半天才发现是序列化方式不一致导致的。保险做法是统一用getBytes(StandardCharsets.UTF_8)。3. 苍穹外卖里的完整落地流程登录、拦截器、双端身份识别3.1 登录接口如何正确颁发Token苍穹外卖中用户登录和管理员登录各有一个Controller入口但核心逻辑是一致的先校验账号密码是否匹配再校验账号状态是否正常然后才轮到生成Token。我当时实现的登录逻辑大致如下PostMapping(/login) public ResultString login(RequestBody LoginDTO loginDTO) { Employee employee employeeService.login(loginDTO); String token jwtUtil.generateToken(employee.getId(), admin); return Result.success(token); }Service层面的校验才是关键部分查询用户、比对密码、检查状态。只有这些都通过了才会走到Token签发这一步。这里要刻意把“业务校验”和“Token生成”解耦不能为了省事把校验逻辑塞进工具类里。否则后期改动密码策略、新增状态校验都要动工具类职责就混乱了。签发出的Token也不是直接扔给前端就完事需要约定前端的存储方式。苍穹外卖项目里前端把Token存起来之后每次请求在拦截器里把它塞到请求头中。后端读取请求头里Authorization字段的值。这里有一个常见面试考点为什么用Authorization而不是自定义一个token头因为Authorization是HTTP标准字段语义清晰很多框架和网关天然支持它的解析自定义头则要自己处理各种边界情况。3.2 用拦截器统一校验身份业务层只认ThreadLocal在苍穹外卖的项目里不可能在每个Controller方法里都写一遍“解析Token、判断用户身份”的逻辑那就太冗余了。我用Spring MVC的拦截器做一个统一的身份校验入口。注册拦截器时指定哪些路径需要登录、哪些路径放行。Configuration public class WebMvcConfig implements WebMvcConfigurer { Autowired private LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) .excludePathPatterns( /employee/login, /user/login, /user/register ); } }注意登录接口本身不能拦截否则用户压根没法完成身份认证静态资源如果项目里存在也要一并放行。这属于一个容易遗漏的细节。拦截器中的核心逻辑是对请求头里的Token做解析并把用户信息存放到ThreadLocal里。为什么用ThreadLocal而不是直接塞进Controller参数因为项目的Service层和Mapper层都可能需要拿到当前登录用户的ID比如下单时要记录“是谁下的单”。通过ThreadLocal可以在一次请求的任意深度随时取到当前用户而不用把参数一层层地传下去。Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); } try { Claims claims jwtUtil.parseToken(token); Long userId Long.valueOf(claims.get(userId).toString()); BaseContext.setCurrentId(userId); return true; } catch (Exception e) { response.setStatus(401); return false; } } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { BaseContext.remove(); } }BaseContext是一个简单的ThreadLocal封装public class BaseContext { private static ThreadLocalLong threadLocal new ThreadLocal(); public static void setCurrentId(Long id) { threadLocal.set(id); } public static Long getCurrentId() { return threadLocal.get(); } public static void remove() { threadLocal.remove(); } }这里必须强调一点afterCompletion里清除ThreadLocal不是可选项而是必选项。Tomcat的工作线程是复用的请求处理完之后线程并不会销毁而是归还给线程池下一轮继续使用。如果不手动清理ThreadLocal下一次请求进来时拿到的可能是上一个用户留下的ID导致“串号”。这是JWT无状态方案里非常典型的一个隐蔽问题我第一次上线排查一个“用户数据串了”的诡异Bug查了半天才定位到是这个原因。3.3 一份Token怎么同时服务用户端和管理端苍穹外卖项目同时跑着C端和B端但它们不需要两套JWT机制只需要在Token的Payload部分多放一个角色标识。用户端登录时生成的Token里role是“user”管理端登录时生成的Token里role是“admin”。后端在拦截器解析后如果某些接口只允许管理员访问就检查role字段如果只允许用户访问也同理。更常见的做法是直接在接口路径上做区分比如/admin/**校验管理员身份/user/**校验C端用户身份再配合注解做细粒度权限控制。苍穹外卖项目里由于接口路径本身已经带上了前缀我主要通过拦截器路径匹配加角色判断两条线来保障既不会影响开发效率也能把权限边界划清楚。这种“统一签发角色区分”的设计思路其实就是更大型系统里RBAC权限模型里很基础的雏形。4. JWT开发里容易踩的坑我的排查记录4.1 Bearer前缀、Base64URL和Base64的差异第一个坑是Token格式。很多请求头的写法是Authorization: Bearer eyJhbGciOi...解析时需要先把“Bearer ”这部分剥离开。我在代码里用了startsWith(Bearer )判断再去掉前7个字符不少初学者会忘了兼容这个前缀。第二个坑涉及到编码问题。JWT的三个部分的编码规则是Base64URL而不是普通的Base64。这两者的差别在于Base64里会用和/两个字符Base64URL为了在URL和请求头里安全传递把这两个字符替换成了-和_。如果按普通Base64去解码某些Token会解析失败。之前用网上工具调试的时候遇到过这种情况后来自己封装工具类时统一使用JWT库自带方法才彻底规避。4.2 过期时间设成多少才算合理这里需要思考清楚一个问题外卖这个场景里用户打开小程序点餐会话特点是“短、频、快”。如果把Token过期时间设置得太久比如7天在手机上等于一次登录长期有效万一手机丢了Token被恶意使用的时间窗口会很长。如果把过期时间设置得太短比如5分钟用户可能正挑着菜呢提交订单时突然提示登录过期体验就很糟糕。我综合权衡后把Token的过期时间设置成30分钟。这个时长足够用户完成一次完整的浏览和下单也不会因为过期窗口太长带来不必要的安全风险。如果你的项目里有更重要的资产或操作还可以更进一步用短期Token做接口鉴权配合一个较长有效期的刷新Token重新换取短期Token实现“滑动续期”。苍穹外卖这个体量不需要做这套但理解这个扩展方向对面试很有帮助。4.3 退出登录以后Token还有效怎么办这是JWT无状态特性带来最直接的问题。传统Session方案里退出登录时服务端删掉session就可以了但JWT的Token在有效期内是独立于服务端状态的服务端无法主动让一个已经签发的Token失效。我在苍穹外卖里采取的方案是前端退出登录时直接删除本地Token后端再把该Token加入一个短期黑名单缓存有效期到Token的过期时间。黑名单的方案严格来说已经不是“完全无状态”了但在真实项目里为了安全可控很多时候不得不引入这么一点状态。如果你的项目不需要彻底踢掉已经登录的设备可以只在客户端删除Token配合比较短的过期时间也能从整体上控制风险。这是我在实际开发中常用的一句话总结JWT的无状态帮我们解决了扩展性的问题但“Token如何撤销”这个问题永远需要结合业务场景额外设计。4.4 Token里到底该放哪些信息一个反直觉的原则是Payload里不能放敏感数据。虽然JWT有签名保护内容没有被篡改但Payload本身只是Base64URL编码不是加密。任何拿到Token的人都可以用工具轻易解码出Payload里的明文内容。所以我在苍穹外卖项目里只放userId、role这些标识性信息密码、手机号、详细地址这类数据一律不往Token里放。如果真的要在无状态Token里携带敏感信息需要引入JWE这样的加密方案给Payload整体做加密这是另一个层面的复杂度。4.5 常见问题排查速查表现象可能原因排查思路登录接口报404拦截器没有放行登录路径检查excludePathPatterns配置Token解析抛异常签名密钥不一致或字符集不同确认生成和解析用同一密钥、同一编码请求头里token没问题但拿不到用户信息请求头名称写错比如size写错前后端统一Header名称名称大小写保持一致用户数据互相串ThreadLocal没有在请求结束后清理检查afterCompletion里是否调用removeToken没过期却提示过期服务器时间不一致确认签发和校验的服务时间同步带Bearer前缀时解析失败未剥离Bearer前缀统一对Authorization头做前缀处理5. JWT背后的设计思路无状态、扩展性与安全边界5.1 为什么JWT适合前后端分离与多端场景前后端分离架构下后端不需要关心前端是浏览器、小程序还是App只要请求头里带着Token就能完成认证这是一种非常干净的交互方式。小程序端天然不依赖Cookie移动端更是如此JWT只需要前端自己能存Token、能带Token就行。苍穹外卖同时有微信小程序风格的用户端和Web风格的管理端共享同一套登录校验逻辑代码层面的复用性就很明显。对比一下传统Session和JWT的差异会更清楚对比项SessionJWT服务端状态需占用服务端内存无状态服务端不存session跨域支持需处理CORS和Cookie请求头携带天然友好横向扩展需要session共享或粘滞会话任意实例都可独立验签主动失效删除session即可需要额外黑名单或短过期时间典型适用传统服务端渲染项目前后端分离、API服务5.2 安全加固的几个实操细节有了Token不代表系统就安全了。我在苍穹外卖的实践里总结出几个不能省的安全操作密钥不要硬编码在代码仓库里最好用配置中心或环境变量管理签名算法不要使用salt钓鱼数据或近期爆出漏洞的算法和过弱的算法HS256本身安全强度在对称算法里足够但密钥长度至少要64位以上登录接口要做频率限制防止暴力尝试。Token过期时间要按业务风险等级设置管理端的Token我设置为比用户端更短的过期时间因为后台操作权限更大被盗的隐患也更严重。还有一个容易被忽略的点服务端校验Token时不能只验证签名通过就算完事还要检查Token里的exp过期时间是否在允许范围内。JWT库一般会内置过期检查但如果你手写解析逻辑很容易忽略这一步。我在第一次手写解析demo时就踩过这个坑解析成功就返回用户身份完全没管过期时间等于任何人都能拿一个永久有效的旧Token反复使用这显然不符合预期。5.3 JWT和周边组件的联动苍穹外卖项目里JWT并不是一个孤立的技术点它和拦截器、ThreadLocal、全局异常处理这些组件形成了完整的鉴权链路。拦截器负责校验身份ThreadLocal负责传递身份全局异常处理负责把校验失败的信息封装成统一的JSON返回结构。理解这条链路比单纯会生成Token更有价值因为面试官问的往往不是“JWT是什么”而是“JWT在你的项目里怎么用的遇到问题怎么处理”。后来我在另一个模拟项目里尝试把JWT接入网关层做了一次统一鉴权改造。思路是网关层统一解析Token把解析出的userId通过请求头传递给下游服务下游服务只认网关传过来的可信身份信息不再自己解析Token。这种模式更适合微服务架构也是从苍穹外卖这种单体项目走向分布式架构时一个很自然的演进方向。说一些我的个人体会做了苍穹外卖这个项目后我对JWT最深的感受是它提供的不是“绝对安全”而是“让服务端无状态地验证身份”的一种思路。它把会话数据从服务端内存搬到了客户端手里用签名保证数据不被篡改用过期时间保证Token不会无限期有效。选型时真正应该权衡的永远是业务场景、扩展性要求和运维成本。面试里如果有人问我“Session和JWT怎么选”我通常会反问一句你的服务需要水平扩展吗你的前端形态是什么样的你能接受Token机制带来的撤销难题吗想清楚了这些问题答案自然就有了。这不是背诵八股文能cover住的内容是真正动手在一个完整项目里跑过一遍之后才有的体感。