
1. 苍穹外卖登录鉴权为什么绕不开JWT最近在跑苍穹外卖这个项目很多人第一次卡住的位置不是SQL、不是Redis而是登录鉴权。登录接口调通了密码也能查对但访问其它业务接口时后端一直返回NOT_LOGIN前端却拿着token死活进不去。这个问题十有八九出在JWTtoken的生成、校验和拦截器放行规则上。苍穹外卖是一个标准的前后端分离项目管理端和用户端都是通过HTTP接口通信。前后端分离就意味着后端不能依赖Session来记住登录状态因为浏览器和后端不在同一个会话上下文里跨端口、跨域名访问时Cookie的维护非常麻烦。项目里采用的方案就是JWTtoken登录成功后后端签发一个token前端存下来后续每次请求把token放到请求头里带回来后端验签通过就放行。这篇文章我按自己实际接入的过程来写覆盖JWT的原理、工具类封装、登录接口改造、拦截器注册以及开发阶段最常见的本地上传图片接口带token访问这条完整链路。适合正在做苍穹外卖毕设、或者想把这个项目写进简历但还没吃透鉴权逻辑的人参考。1.1 没接入JWT之前登录流程长什么样苍穹外卖至少有四个角色会涉及登录管理端员工登录、用户端微信登录还有后续扩展的骑手登录。不管哪个角色登录校验的诉求都一样账号密码对不对对了之后怎么让后端认识这个已登录的身份。先看一眼项目里登录接口的原始逻辑。控制层接收用户名和密码Service层把密码做一次MD5加密然后去数据库比对。比对成功就说明登录这件事成了但问题来了——HTTP是无状态的下一次请求来了后端根本不知道这个请求到底是不是刚才那个登录成功的人发的。// 登录成功只是查到了用户还缺少身份凭证这一步 Employee employee employeeService.login(employeeLoginDTO);密码核对正确之后employee对象里只有用户ID、用户名这些数据库字段。要让它变成可识别的登录身份必须生成一个凭证交到前端手上。Session方案会让后端在内存里维护一份sessionId和用户信息的映射而JWT方案则是把用户信息做签名后直接交给前端保存后端不存任何会话状态。苍穹外卖选择后者核心原因就是无状态、好扩展、不占服务端内存。1.2 JWT和Session、普通Token有什么区别很多人第一次接触JWT时容易把它和普通随机字符串Token搞混。普通Token是随机生成的一串字符串后端要把这个字符串和用户信息存到Redis或数据库里请求来了之后查库确认。JWT则是把用户信息经过Base64URL编码后拼接成三段字符串再用密钥做签名后端不需要查库只需要本地验签就能确认token的真伪。做个类比普通Token像商场发的纸质寄存牌牌子本身没有意义工作人员拿着牌子去后仓翻找对应的包裹。JWT则像一张带防伪印章的通行证印章是密钥签出来的门卫一看印章就知道是真的不用打电话回公司确认。Session、Redis Token、JWT三者的核心差异在下表里能看得很清楚方案服务端是否存状态用户信息如何获取分布式扩展难度典型场景Session存内存或RedissessionId查服务端需要统一存储服务端渲染项目Redis Token存Redistoken查Redis依赖Redis高安全性要求JWT不存解析token本身无共享存储压力前后端分离项目苍穹外卖选择JWT而不是Redis Token还有一个实际原因课程项目里登录态只是用来做接口鉴权不涉及踢人下线、token失效统计等复杂需求JWT实现起来最短平快。后续如果要做用户被禁用立即踢下线这类强管控JWT反而麻烦因为token在到期前无法被服务端主动作废。1.3 JWT在苍穹外卖里承担的职责边界在苍穹外卖项目里JWT只做一件事验证当前请求是否来自已登录用户。它不负责权限判断哪类角色能访问哪个接口由Spring的权限控制或业务层自行判断它也不负责保存业务数据用户ID只是解析出来之后临时放到ThreadLocal里供本次请求使用。我见过有人为了省事把用户手机号、角色列表、甚至头像URL全塞进JWT的Payload里这是不对的。JWT的Payload虽然可以存业务数据但它是Base64URL编码的等于明文任何人解码都能看到内容。里面只放用户ID、用户名、角色ID这类非敏感标识字段就够了。苍穹外卖常规做法是放员工ID、用户名和过期时间用户端微信登录场景则放openid和用户ID。2. 先把JWT三段式结构吃透后面接入才不会瞎调2.1 用拆信封的方式理解JWTJWTtoken长得像一串密密麻麻的字符用点号分成三段eyJhbGciOiJIUzI1NiJ9.eyJlbXBJZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsImV4cCI6MTcxODIxMDAwMH0.3mF6zV8QyLxE7kD90pNpL2WfGjJ4n_g5tQ6sX8yRrT4第一段是Header声明了签名算法和token类型第二段是Payload存了用户标识、过期时间等数据第三段是签名由密钥对前两段内容做哈希后生成的防伪标识。我在第一次接触时总是记不住这三段谁先谁后后来用信封来记忆Header相当于信封上的邮戳写清楚寄信用什么加密规则Payload相当于信纸上的正文写的是实际要传递的信息Signature则像信封的火漆封印一旦有人拆开改过信纸内容封印就对不上了收信人就知道信被篡改过。拿到一个JWT后把前两段丢进Base64解码就能看到明文内容。上面例子的Payload解码后大概是这样的结构{ empId: 1, username: admin, exp: 1718210000 }这才是JWT的本质它不是一个不透明的随机字符串而是一个可以被任何人阅读、但不能被任何人伪造的签名文档。2.2 Header、Payload、Signature各管什么Header部分在苍穹外卖的JJWT实现里通常长这样{ alg: HS256, typ: JWT }Payload部分是业务信息的存放位置。苍穹外卖里我在生成token时放的是empId、username和过期时间exp。注意exp是一个Unix时间戳单位是秒不是毫秒。很多人第一次写的时候把System.currentTimeMillis()直接塞进去结果token秒变已过期这个坑后面详说。Signature是整段token里唯一需要密钥参与的部分。它的计算方式是先用HMAC-SHA256算法对Header.Base64URL编码值.Payload.Base64URL编码值做一次哈希再把结果做Base64URL编码。只要Header或Payload里任何一个字符被改动算出来的签名就和原来的对不上验签就会失败。这里有一个容易忽略的细节Base64URL编码不是普通的Base64。普通Base64编码结果里会出现、/和这三个字符但URL中这些字符有特殊含义所以JWT规范规定要把替换成-/替换成_并去掉末尾的。使用Hutool或JJWT自带的编解码工具能够自动处理但如果你手写Base64解码就可能会栽在这个细节上。2.3 JJWT库处理签名时的实际行为苍穹外卖项目引入JJWT依赖后生成token的代码核心逻辑是调用Jwts.builder()验签时调用Jwts.parser()。这两个方法看起来黑盒但内部实现做的事情就是上面说的三段式组装和签名校验。我入这个项目时用的是JJWT 0.9.1版本这是教材里比较常见的版本。它有个特点是解析时传密钥用字符串而0.11.x版本要求传SecretKey对象两者的API差异比较大。dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependencyJJWT 0.11.x的API把Key管理做了强化直接用SecretKeySpec或Keys.hmacShaKeyFor()构建密钥对象密钥长度不够会直接抛异常。如果你下载的项目模板用了0.9.1新代码里照抄网上0.11.x的教程就会编译不过。这是很多人在版本差异上白白浪费半天时间的重灾区。3. 苍穹外卖JWT接入的完整落地工具类、登录改造、拦截器3.1 配置文件里的JWT参数怎么设计苍穹外卖在application.yml里会预留一段JWT相关配置设计得很直白sky: jwt: # 管理端员工生成jwt令牌相关配置 admin-secret-key: itcast admin-ttl: 7200000 admin-token-name: token这段配置里三个字段的含义分别是签名密钥、token有效期毫秒、请求头里token对应的参数名。admin-token-name这个字段很多人不理解为什么要有它其实是在拦截器里从请求头取token时用的标识。前端约定俗成会把token放在请求头的token字段里但有的项目会叫Authorization或X-Token做成配置项是为了方便切换。用户端微信登录的配置会在下面再加一组user-secret-key、user-ttl、user-token-name管理端和用户端的密钥、有效期相互独立避免同一套密钥跨角色通用带来的安全隐患。3.2 工具类封装生成token和解析token两个方法苍穹外卖教材通常会提供一个JwtUtils或JwtHelper工具类我在做的时候自己重新封装了一遍只保留两个需要暴露给外部的方法createJwt和parseJwt。public class JwtUtils { private static final String SECRET_KEY itcast; public static String createJwt(MapString, Object claims, long ttlMillis) { return Jwts.builder() .setClaims(claims) .setExpiration(new Date(System.currentTimeMillis() ttlMillis)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); } public static Claims parseJwt(String token) { return Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody(); } }这里有几个细节值得展开。setClaims接收的是一个Map放到Payload里会变成JSON对象的字段。我在登录接口里这样组装claimsMapString, Object claims new HashMap(); claims.put(empId, employee.getId()); claims.put(username, employee.getUsername());exp过期时间由setExpiration自动写入。ttl传的是配置文件里的7200000毫秒也就是2小时。2小时这个有效期对管理端后台来说算合理但如果你在开发时反复调试接口token过期后又要重新登录非常烦人。我开发阶段把ttl临时改成8小时测试完再改回来能省不少事。parseJwt方法返回Claims对象Claims继承了Map接口所以解析成功后可以直接通过claims.get(empId)取出员工ID。注意parseClaimsJws和parseClaimsJwt的区别带s的是验签完整token不带s的只解析未签名token。实际开发中一定要用带s的方法否则等于没校验。3.3 登录接口改造登录成功才签token改造前登录接口返回的是employee对象改造后要返回一个LoginVO里面包含员工基本信息和一个新生成的token字段。代码写起来很直观PostMapping(/login) public ResultLoginVO login(RequestBody EmployeeLoginDTO employeeLoginDTO) { log.info(员工登录{}, employeeLoginDTO.getUsername()); Employee employee employeeService.login(employeeLoginDTO); // token 相关配置注入后生成 MapString, Object claims new HashMap(); claims.put(empId, employee.getId()); claims.put(username, employee.getUsername()); String token JwtUtils.createJwt(claims, jwtProperties.getAdminTtl()); LoginVO loginVO LoginVO.builder() .id(employee.getId()) .userName(employee.getUsername()) .name(employee.getName()) .token(token) .build(); return Result.success(loginVO); }登录失败的情况由Service层直接抛出异常全局异常处理器返回错误提示。登录成功后才走生成token这段逻辑所以token一定对应着真实存在的用户。这里有个容易被忽略的点生成token时使用的claims是业务接口里后续要用的用户信息。不要在这里图省事把所有数据库字段全塞进去Payload体积越大生成的token字符串越长每次请求都要多传几百字节。只放ID和用户名就够了其它信息可以通过ID再去查。3.4 拦截器验签与ThreadLocal存取登录用户有了token还不行必须在请求进入Controller之前先验一遍。苍穹外卖的做法是写一个拦截器类实现HandlerInterceptor接口在preHandle方法里完成token解析。public class JwtTokenAdminInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(token); if (StringUtils.hasText(token)) { try { Claims claims JwtUtils.parseJwt(token); Long empId Long.valueOf(claims.get(empId).toString()); BaseContext.setCurrentId(empId); return true; } catch (Exception ex) { response.setStatus(401); return false; } } response.setStatus(401); return false; } }这段代码里最关键的其实是BaseContext。它是苍穹外卖项目里一个基于ThreadLocal的上下文工具类负责在本次请求内传递当前登录用户ID。拦截器验签通过后把empId放进去Service层随时可以取出来使用。public class BaseContext { public static ThreadLocalLong threadLocal new ThreadLocal(); public static void setCurrentId(Long id) { threadLocal.set(id); } public static Long getCurrentId() { return threadLocal.get(); } }为什么用ThreadLocal而不是直接传参因为HTTP请求从Controller到Service再到Mapper链路很长如果靠方法参数一层层传用户ID每个方法签名都要改。ThreadLocal能保证同一线程内任何地方都能拿到这份数据。要注意的是请求结束后的清理工作可以在afterCompletion里做否则线程池复用线程时数据会串号。验签失败为什么状态码用401这是RESTful接口约定俗成的做法401代表未认证和403已认证但无权限区分开。前端收到401后通常会跳回登录页或清理本地token。3.5 拦截器注册时最容易漏掉的放行规则拦截器写完后必须注册到Spring MVC中这一步在WebMvcConfigurer配置类里完成public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtTokenAdminInterceptor) .addPathPatterns(/admin/**) .excludePathPatterns(/admin/employee/login); } }这段配置的含义是拦截所有以/admin/开头的请求但放行登录接口本身。因为用户还没登录你不可能要求他带着token来访问登录接口。这个注册路径是苍穹外卖JWT接入里最容