
做了几年Java后端我越来越觉得很多项目卡在登录认证这一关不是不会写接口而是没把 token 的生命周期当回事。苍穹外卖这个项目我前后完整写过两遍第一遍跟着教程敲第二遍自己从零重构每次都在 JWT token 这里绕好几圈。管理端和用户端两套登录登录后都会签发一个 JWT token后续请求全靠这个 token 识别用户身份拦截器统一校验ThreadLocal 保存当前登录用户信息。整个过程非常适合拿来理解 Java Web 里最经典的登录态方案也更适合用来排查那些“登录成功后过一会儿就失效”“换个接口就 401”之类的问题。这篇内容我不打算讲太玄的概念就按我实际写苍穹外卖时踩过的坑、看过的代码和最终留下的方案来聊。如果你是刚学完 Spring Boot想找一个能落地的 token 鉴权模板或者你已经写完苍穹外卖但发现自己只是照着抄了 JwtUtil 和拦截器遇到问题依然不知道怎么排查那这篇对你有用。1. 为什么苍穹外卖必须用 JWT 做登录认证1.1 Cookie、Session、Token 三兄弟先理清楚再动手HTTP 协议天生是无状态的。用户发一次请求服务器处理完就结束了下一次请求来了服务器根本不记得这个人是谁。早期的 Web 项目用 Cookie Session 解决记忆问题用户登录成功后服务器在内存里存一份 session再把 sessionId 通过 Set-Cookie 塞给浏览器浏览器每次请求自动带上这段 Cookie服务器拿 sessionId 去查对应的 session 对象查到就说明用户登录过。这套方案在单体应用里非常好用但如果前后端分离尤其是小程序、H5、桌面管理端并存的时候Cookie 的自动携带就没那么友好了。小程序里发请求不一定走浏览器 Cookie 机制管理后台如果是独立前端部署跨域时 Cookie 的维护成本也不低。更重要的是Session 数据存在服务器内存里多台服务器部署时还得考虑 session 共享要么用 Spring Session Redis要么做粘性会话成本一下就上去了。Token 方案把“存储”从服务器端转移到了客户端。登录成功后服务器签发一个 token 字符串返回给前端前端自己保存以后每个请求在 Header 里显式带上这个 token。服务器不再需要保存会话记录只需要校验 token 本身是否合法。这样做的好处是天然支持前后端分离也方便水平扩展。JWTJSON Web Token是 token 方案里最流行的一种它本身就是一串有结构的字符串能被解析和验签所以非常适合当苍穹外卖这种单体项目的登录凭证。1.2 苍穹外卖两套账号体系登录痛点到底在哪苍穹外卖不是一套账号走天下它有两个入口管理端给员工用用户端给 C 端消费者用。管理端登录走/admin/employee/login用户端登录走/user/user/login对应的账号表分别是员工表和用户表。如果不用 JWT最容易出现的局面是每个接口都自己判断“当前是谁在操作”。比如管理端的菜品管理接口要判断这个人是不是已经登录的员工用户端的下单接口要判断这个用户是不是有效用户。如果每个 Controller 都去写一段从 Cookie 或 Header 取登录态的逻辑代码会非常臃肿而且很容易漏。实际项目里更需要的是统一走拦截器在请求进入 Controller 之前就把登录校验做掉。另外苍穹外卖的管理端和用户端是两套不同的权限体系。管理端接口路径普遍以/admin/**开头用户端以/user/**开头。这意味着我们可以针对两套路径配置完全不同的拦截规则。如果使用 Session通常只能靠路径去区分不同会话域不够直观但用 JWT我们可以在 token 的载荷里标记用户角色和用户 id拦截器解析后清楚知道这个 token 到底是员工 token 还是用户 token逻辑非常干净。1.3 选 JWT 的真正理由与它的边界JWT 的核心优势可以概括成三个词无状态、可验证、跨端通用。但 JWT 不是万能药它在苍穹外卖这种单体项目里也有明显边界。JWT 无状态意味着 token 一旦签发在过期之前都是有效的。服务器没有办法主动把这个 token 作废。如果员工被禁用账号只要他的 token 还没过期他仍然可以访问接口。所以实际项目里不能只依赖 JWT通常还需要配合账号状态校验、Redis 黑名单等方案做补偿。另一个边界是 JWT 的载荷不要存敏感信息。JWT 的 Payload 虽然不能被篡改但它是 Base64URL 编码的任何拿到 token 的人都可以直接解码看到里面的内容。如果往里面塞密码、手机号、身份证号等于把这些信息交到了客户端手里。苍穹外卖里 JWT 载荷只需要存员工 id 或用户 id 这类非敏感标识就够了其他用户信息在查到数据库后再拿。2. 苍穹外卖 JWT 认证的完整链路与核心设计2.1 一条 token 从签发到校验的完整流程我习惯把 JWT 认证理解成三个阶段登录时签发、请求时校验、业务里取人。第一阶段客户端调用登录接口带上用户名和密码。服务端先查数据库确认账号存在且密码正确然后把员工 id 或用户 id 放进 JWT 的 claims用密钥签名生成 token 字符串最后在登录接口的返回体里把这个 token 交给前端。第二阶段前端拿到 token 之后每次请求在 HTTP Header 里加Authorization: Bearer token。后端配置拦截器拦截指定的接口路径。拦截器先从 Header 里拿 token没有 token 直接返回未登录有 token 则解析并验签解析失败或超时也返回未登录只有在 token 合法的情况下才放行到 Controller。第三阶段Controller 里怎么知道当前登录用户是谁一种方式是从前端传来的参数里拿但参数不可信不能依赖。更靠谱的方式是拦截器在验签通过后把 claims 里的员工 id 或用户 id 存到 ThreadLocal 里业务代码直接从 ThreadLocal 取当前用户 id。这样既能保证来源可信又不需要在每个 Controller 里重复解析 token。这三个阶段听起来简单但每个阶段都有细节。比如登录返回的 token 是放 data 里还是单独字段前端把 token 放在 Header 的什么位置拦截器放行了哪些路径ThreadLocal 什么时候清理这些细节决定了这套方案能不能稳定跑下去。2.2 JWT 工具类落地密钥、过期时间、载荷怎么设计苍穹外卖里一般会封装一个 JwtUtils 或者 JwtUtil 工具类核心方法就两个createJWT和parseJWT。一个负责生成 token一个负责解析并验签。我自己的实现偏好是这样的Component public class JwtUtils { Value(${sky.jwt.admin-secret-key}) private String adminSecretKey; Value(${sky.jwt.admin-ttl}) private long adminTtl; Value(${sky.jwt.user-secret-key}) private String userSecretKey; Value(${sky.jwt.user-ttl}) private long userTtl; public String createAdminToken(Long employeeId) { MapString, Object claims new HashMap(); claims.put(empId, employeeId); return createToken(adminSecretKey, adminTtl, claims); } public String createUserToken(Long userId) { MapString, Object claims new HashMap(); claims.put(userId, userId); return createToken(userSecretKey, userTtl, claims); } private String createToken(String secretKey, long ttl, MapString, Object claims) { return Jwts.builder() .setClaims(claims) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() ttl * 1000)) .signWith(Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token, String secretKey) { return Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8))) .build() .parseClaimsJws(token) .getBody(); } }这里有一个非常关键的点密钥必须足够长。HS256 算法是基于 HMAC-SHA256 的对称签名密钥太短很容易被暴力破解。如果密钥字符串只有六七个字符虽然能跑通但安全性几乎为零。所以我建议至少准备 32 个字节以上的字符串。苍穹外卖这个项目里即便只是学习用途也应该养成好习惯。过期时间的设计也要分角色。员工管理端使用频率高token 过期时间一般不要设太长我习惯设为 2 小时。用户端可以稍微长一点比如 7 天但也要看业务。如果想做“记住我”可以后面做续签见第 5 章。载荷部分只需要放 id 这类必要信息。不要放密码不要放手机号。JWT 的 Payload 虽然被签名保护篡改会被发现但内容是明文编码任何人拿到都能 Base64 解码看个精光。2.3 拦截器与 ThreadLocal用户身份如何跨接口传递JWT 工具类只是钥匙真正让整套鉴权跑起来的是拦截器。苍穹外卖里通常会有两个拦截器一个管管理端一个管用户端也可以共用一个拦截器类、通过构造器或配置传入不同密钥。我比较推荐直接写两个拦截器虽然代码上有一点冗余但语义更清楚。管理端拦截器只处理/admin/**用户端拦截器只处理/user/**各自的密钥、过期时间、claims 字段名都不一样混在一起反而容易出错。拦截器的核心逻辑长这样public class JwtTokenAdminInterceptor implements HandlerInterceptor { Autowired private JwtUtils jwtUtils; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 从请求头中获取令牌 String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); } // 2. 校验令牌 try { Claims claims jwtUtils.parseToken(token, jwtUtils.getAdminSecretKey()); Long empId claims.get(empId, Long.class); BaseContext.setCurrentId(empId); return true; } catch (Exception ex) { response.setStatus(401); response.getWriter().write(NOT_LOGIN); return false; } } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { BaseContext.removeCurrentId(); } }这里有两个容易忽略的细节。第一前端传 token 时不一定都带Bearer前缀需要兼容两种写法否则自己写 Postman 测试时很容易出现 token 解析失败。第二登录接口本身不能被这个拦截器拦掉所以注册拦截器时要把登录路径排除在外。ThreadLocal 是这套方案里传递用户身份的利器但也需要小心。ThreadLocal 绑定的数据只对当前线程可见如果请求结束后不清理Tomcat 线程池复用时下一次请求可能拿到上一次残留的用户 id造成用户信息串号。所以务必在afterCompletion里执行清理。苍穹外卖项目里一般会提供一个 BaseContext 工具类内部维护一个ThreadLocalLong提供setCurrentId、getCurrentId、removeCurrentId三个静态方法。业务代码里要用当前登录用户时直接BaseContext.getCurrentId()就好。2.4 配置文件与密钥管理密钥、过期时间这些参数不要写死在工具类里放在配置文件里既方便修改也方便不同环境用不同配置。比如管理端和用户端就建议用两套不同的密钥防止用户端 token 被拿去调管理端接口。配置示例sky: jwt: admin-secret-key: 苍穹外卖管理端JWT密钥请至少32位 admin-ttl: 7200 user-secret-key: 苍穹外卖用户端JWT密钥请至少32位 user-ttl: 604800如果你是在本地学习直接写在 application.yml 里没问题。如果是在真实项目里建议把密钥放到环境变量或配置中心不要提交到 Git 仓库。密钥一旦泄露攻击者就能自己签发任意 token等于整个登录系统被脱了衣服。另外生产环境强烈建议给 JWT 配置一个签发时间iat和过期时间exp。签发时间用来记录 token 产生时刻过期时间用来控制有效期。很多初学者只设置了过期时间没有设置签发时间虽然问题不大但排查问题时少了一条线索。3. 实操记录登录接口签发 token拦截器统一校验3.1 从依赖到项目结构先搭好 JWT 所需的基础苍穹外卖项目本身是 Spring Boot 工程要在里面启用 JWT第一步是引入对应依赖。不同版本的 Spring Boot 对应的 JWT 依赖有差异比较常见的旧版坐标是dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency如果你是较新的 Spring Boot 3.x也可以使用io.jsonwebtoken:jjwt:0.11.5之后的新写法。重点不是版本号而是要理解jjwt-api是编译期接口jjwt-impl和jjwt-jackson是运行期实现三者都必须引入。依赖加好之后项目里建议按职责分包utils放 JWT 工具类interceptor放拦截器context放 ThreadLocal 工具类config放 WebMvc 配置类。分包清晰之后后面排查问题会省很多时间。3.2 登录接口改造密码校验通过后生成 token登录接口的改造是整个环节里最直观的一部分。原来登录接口的逻辑可能只是查库、比对密码、返回员工信息改造之后多了一步密码匹配成功后创建 JWT 并放进返回结果。以管理端登录为例Service 里的核心逻辑可以写成public Employee login(EmployeeLoginDTO employeeLoginDTO) { String username employeeLoginDTO.getUsername(); String password employeeLoginDTO.getPassword(); Employee employee employeeMapper.getByUsername(username); if (employee null) { throw new AccountNotFoundException(账号不存在); } // 苍穹外卖里密码一般用 MD5 加密存储 if (!DigestUtils.md5DigestAsHex(password.getBytes()).equals(employee.getPassword())) { throw new PasswordErrorException(密码错误); } if (employee.getStatus() 0) { throw new AccountLockedException(账号已被锁定); } return employee; }Controller 层拿到登录成功的员工对象后再生成 tokenPostMapping(/login) public ResultEmployeeLoginVO login(RequestBody EmployeeLoginDTO employeeLoginDTO) { Employee employee employeeService.login(employeeLoginDTO); String token jwtUtils.createAdminToken(employee.getId()); EmployeeLoginVO employeeLoginVO EmployeeLoginVO.builder() .id(employee.getId()) .userName(employee.getUsername()) .name(employee.getName()) .token(token) .build(); return Result.success(employeeLoginVO); }这里有一点需要注意业务逻辑里不要混入太多 JWT 生成代码。登录成功后要不要签发 token是接口层的事情要不要校验密码是服务层的事情。如果 Service 里既校验密码又生成 token后面如果用户端登录也要生成不同格式的 tokenService 就膨胀了。3.3 拦截器编写与注册哪些路径放行哪些必须拦截拦截器写完之后要注册到 WebMvc 配置里才能生效。苍穹外卖一般会有 WebMvcConfiguration 类实现WebMvcConfigurer重写addInterceptors方法。管理端拦截器注册示例Configuration public class WebMvcConfiguration implements WebMvcConfigurer { Autowired private JwtTokenAdminInterceptor jwtTokenAdminInterceptor; Autowired private JwtTokenUserInterceptor jwtTokenUserInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtTokenAdminInterceptor) .addPathPatterns(/admin/**) .excludePathPatterns(/admin/employee/login); registry.addInterceptor(jwtTokenUserInterceptor) .addPathPatterns(/user/**) .excludePathPatterns(/user/user/login); } }注册拦截器时最容易出问题的就是路径匹配。/admin/**这种写法在 Spring 的路径规则里是匹配/admin/xxx和/admin/xxx/yyy但不会匹配/admin本身。如果你希望/admin/employee/login被放行就必须把完整路径写到excludePathPatterns里。我见过不少同学把登录接口写在/admin/login或者/admin/employee/login/带斜杠结尾结果路径和放行配置对不上请求一来直接被拦截器拦掉。建议写完配置之后先不要急着调业务直接用 Postman 打一次登录接口如果返回 401先检查是不是路径配置的问题。3.4 用 Postman 走一遍完整的登录与鉴权流程本地启动苍穹外卖项目后我习惯按下面的顺序验证 JWT 链路。第一步请求登录接口。以管理端为例POST/admin/employee/loginBody 里放{username:admin,password:123456}。如果配置没问题返回结果里会有一个 token 字段。第二步不带 token 访问一个受保护的接口比如 GET/admin/category/list。预期结果是 401 或者固定提示比如NOT_LOGIN。这一步主要验证拦截器确实生效了。第三步复制登录返回的 token放到 Postman 的 Authorization 页签选择 Bearer Token粘贴进去再请求同一个接口。这次应该能正常返回业务数据。很多人到第三步就开始飘了觉得 JWT 已经搞定。其实你还需要验证第四步改掉 token 里的任意一个字符再请求接口。如果系统返回 401说明验签逻辑正常如果还能通过说明签名校验有问题大概率是解析代码写错了。这个测试很重要能帮你区分“只是能从 Header 取出 token”和“真正校验了 token 合法性”的区别。4. 常见问题与排查技巧实录4.1 token 失效、token 过期前端还在用旧 token 怎么办这是最常被问的问题之一。明明登录成功了过了一段时间再操作突然返回未登录。原因很简单token 的exp过期时间到了服务器验签时发现已经超过有效期主动拒绝请求。排查思路分三步第一步看前端在请求头里到底有没有把 token 传上来第二步看 token 的过期时间是多长是不是设置得太短第三步看服务器时间和前端时间是否有明显差异。如果服务器系统时间被改过可能导致 token 签发后立刻被认为过期。如果你需要让用户在较长时间内保持登录可以考虑两种办法。一种是直接把过期时间调长简单粗暴但对安全要求高的场景不推荐。另一种是做一个续签机制当 token 剩余有效时间低于某个阈值时通过 refresh_token 或重新登录换取新 token。具体方案见 5.1。4.2 拦截器放行路径配置错登录接口被自己拦住了有一种非常典型的报错后端启动后前端调登录接口直接收到“未登录”。你第一反应是去查登录逻辑但查了半天发现登录逻辑没错最后才意识到是拦截器把/admin/employee/login也拦住了。为什么会出现这个问题因为很多人在注册拦截器时没有写excludePathPatterns或者写了但路径和接口实际路径对不上。Spring 的路径匹配规则是精确匹配和通配符匹配路径末尾多一个少一个斜杠都可能导致匹配失败。我建议在excludePathPatterns里把登录、Swagger 文档、静态资源等无需鉴权的路径都单独列出来并且在配置类里加日志输出。启动时看到拦截器注册的拦截路径和放行路径心里就有底了。4.3 ThreadLocal 没清理导致用户信息串号ThreadLocal 用起来很方便但不能不清理。Tomcat 的工作线程是复用的线程处理完一个请求后可能被放回线程池下次再处理另一个请求时ThreadLocal 里的旧值还在。具体表现是用户 A 登录后请求商品列表用户 B 登录后请求同一个接口按理说应该各自看到各自的数据但由于线程复用B 的请求可能拿到 A 的用户 id导致数据错乱。这种问题非常隐蔽不是每次都会复现而且排查起来很费劲。解决办法是在拦截器afterCompletion里调用BaseContext.removeCurrentId()。小程序、管理端两个拦截器都要加。这个技巧如果教程里没强调自己一定要记住。4.4 JWT 安全加固常见漏洞与规避手段JWT 本身不是绝对安全如果使用不当会有几个典型漏洞。第一是弱密钥问题。HS256 是对称签名密钥就是签名的“密码”如果密钥太简单攻击者可以暴力枚举然后自己伪造 token。密钥建议至少 256 位并且不要包含常见的单词、日期、键盘连续键。第二是算法混淆漏洞。JWT 的 Header 里有一个 alg 字段如果服务端没有限制算法攻击者可以把 alg 改成none或利用非对称算法和对称算法的验签方式不同来伪造 token。实现时最好固定签名算法不要接受none。第三是载荷泄露问题。不要在 token 里放密码、手机号、身份证号等敏感信息因为 Payload 是明文编码的。如果有人截获了 token用 Base64 解码就能看到。第四是没有主动失效机制。员工离职了、用户被封了token 仍然有效。实际可以引入 Redis 黑名单把需要主动失效的 token 存到黑名单里每次校验时先查黑名单命中则认为无效。5. 扩展思考token 续签、多端登录与本地上传场景5.1 token 续签怎么做刷新 token 是否适合苍穹外卖如果嫌弃固定过期时间不够灵活可以做一个简单续签。单体项目不一定要上 OAuth 那套 refresh_token 体系可以使用双 token 或响应头续签。双 token 方案是登录时同时返回 access_token 和 refresh_token。access_token 过期时间短比如 2 小时refresh_token 过期时间长比如 7 天。前端发现 access_token 过期后带着 refresh_token 调用统一刷新接口服务端验证 refresh_token 合法后签发新的 access_token。这种方案需要额外维护 refresh_token 的有效性通常会把 refresh_token 存在数据库或 Redis方便撤销。响应头续签方案更轻量拦截器在解析 token 时发现剩余有效期已经低于总有效期的一半就自动生成一个新 token 放在响应头里前端在响应头里取到新 token 就替换本地 token。这个方案的优势是不需要额外接口但需要前端配合而且每次续签都会刷新过期时间可能让 token 永不过期。对于苍穹外卖这类学习项目我建议先不要过度设计固定过期时间加到期后重新登录就足够了。续签的优先级排在业务功能之后等双端登录、权限拦截都稳定了再考虑也不迟。5.2 管理端与用户端 token 隔离的实践经验苍穹外卖里的员工和用户是两套完全独立的账号体系我在项目中始终坚持一个原则管理端 token 和用户端 token 使用不同的密钥加载不同的 claims 字段。有人觉得用同一个密钥、都放 id 不就够了不够。如果同一个密钥用户端 token 被恶意用户拿到只要他把 token 里的 id 改成员工 id并且访问/admin/**接口理论上身份验证能通过。因为服务端只是验签和解析 claims它不知道这个 token 本应是给用户端用的。虽然员工 id 和用户 id 可能不好蒙但风险没必要留着。所以我在JwtUtils里会写两个创建方法一个createAdminToken一个createUserToken两个解析方法也分开。管理端拦截器只认管理端密钥用户端拦截器只认用户端密钥两套体系互不干扰。5.3 本地上传图片接口token 应该怎么携带苍穹外卖涉及菜品图片上传如果做了本地上传图片功能前端通常用multipart/form-data提交文件。这个场景下很多人会把 token 放在 Form Data 的参数里比如和图片一起提交一个tokenxxx字段。在单体演示项目里这也能跑通但不是最佳实践。更规范的做法是把 token 放在请求头Authorization里文件本身通过 Form Data 上传。这样拦截器逻辑不用区分是一般接口还是文件上传接口统一从头取 token。需要注意的是上传接口和普通 JSON 接口的前端封装可能不一样要确保文件上传时请求头也会带上 token。另外上传接口本身要不要拦截要看业务需要。如果图片上传后需要定位到具体用户就要求登录如果允许匿名上传就不拦截。这个决定要提前想清楚不要因为懒直接用/admin/**全拦。5.4 从单体到微服务JWT 方案如何平滑演进苍穹外卖是一个单体项目JWT 在单体里的实现相对简单拦截器在同一个进程里做校验ThreadLocal 在同一进程内传递信息。如果以后项目拆成微服务JWT 依然可以沿用但要注意几个变化。第一校验点从服务内的拦截器前移到网关层。网关统一拦截请求完成 token 校验和用户身份解析再把用户信息通过 Header 转发给下游服务。这样业务服务不需要重复实现 JWT 解析逻辑。第二密钥管理变得更重要。多个服务要能验签同一个 token必须共享同一套密钥或公钥。使用对称密钥时密钥只能放在配置中心或环境变量里不能散落各服务仓库。第三主动失效更麻烦。单体里用 Redis 黑名单可能还比较好维护微服务里就需要统一的 Redis 或内存缓存服务。如果业务对安全要求高也可以在网关层把 token 换成内部临时凭证下游服务只信任内部凭证不直接信任原 JWT。我个人实操中的一个体会是JWT 真正让人舒服的不是什么玄学技术而是当你把“生成 token、校验 token、传递用户”这三个环节彻底拆开之后出问题时能一眼定位到断点。苍穹外卖这个项目把这三件事全都串了起来你只要亲手敲一遍再去调几个 401 的 bug对登录态的理解会比看十篇理论文章都有用。后面如果让我给这个项目加功能我会优先加 Redis 黑名单和用户端续签既不破坏现有结构又能把 JWT 方案真正补成一个可上线的闭环。