Spring Boot双Token登录认证落地与踩坑指南

发布时间:2026/9/10 11:58:23
Spring Boot双Token登录认证落地与踩坑指南 做项目的时候接到一个需求要求把原先的单token登录升级成双token认证也就是大家常说的“access token refresh token”模式。当时第一反应是这不就是JWT加一个续签接口嘛能有多复杂真正动手之后才发现如果只是照着网上的demo把两个token发出去后续的续签逻辑、过期时间策略、注销处理、并发场景这些问题全都会冒出来。这篇文章把我从设计到落地、再到上线后排查问题的完整过程记录下来重点讲清楚每个决策背后的原因以及那些文档里不会写的坑。项目基于Spring Boot实现如果你正在做登录认证模块或者想了解双token方案怎么落地这篇文章应该能帮你少走不少弯路。1. 项目概述与双token方案的设计思路1.1 单token方案的核心痛点先说我们为什么要从单token改成双token。之前的系统用的是标准的JWT单token方案用户登录成功后服务端生成一个JWT返回给前端前端每次请求都把这个token放在请求头里后端通过拦截器校验token的合法性。这套方案在初期确实够用但随着业务复杂化问题逐渐暴露出来。最核心的问题是JWT的过期时间怎么定都不合适。如果把过期时间设得很短比如30分钟用户在使用过程中频繁遇到token过期体验非常差而且前端需要反复弹出登录框让用户重新输入账号密码如果把过期时间设得很长比如7天那token一旦泄露攻击者就能在7天内持续访问用户数据安全风险极高。更麻烦的是JWT是一个无状态token服务端无法主动让它失效。也就是说用户点击“退出登录”之后这个token在过期之前依然是有效的你只能在Redis里维护一个黑名单把已注销的token加进去但这又引入了额外状态JWT本身的“无状态”优势就不存在了。在实际运营中我们还碰到一个场景用户的密码被修改了或者账号被管理员禁用这时所有已签发的token该如何处理在纯单token方案下要么等token自然过期要么依赖一个全局的token版本号来强制失效这都需要在服务端维护额外状态。归根结底单token方案在过期时间、主动失效、用户体感这三个维度上很难兼顾。1.2 双token方案的取舍分析双token方案的基本思路是把“访问凭证”和“刷新凭证”分开管理。access token负责实际的资源访问它的有效期很短通常设置为15分钟到2小时refresh token只用于换取新的access token有效期相对较长可以是7天、14天甚至30天。用户登录时服务端同时签发两个tokenaccess token过期后前端拿到refresh token去请求刷新接口换取新的access token整个过程中用户完全无感知。这个方案的优点在于access token短命即使泄露了攻击者的利用窗口很小refresh token虽然有效期长但它只被发送到刷新接口而不是每次请求都携带所以暴露面也小得多。更重要的是refresh token是可以被服务端主动失效的——比如存储在Redis里用户注销时直接删掉对应记录refresh token即刻作废从而间接实现了“让用户下线”的能力。当然双token也不是银弹。它引入了一个新的接口、新的状态存储还带来了refresh token泄露后如何处理的难题。所以网上也有人说这是“用复杂度换安全性”我认同这个判断。但是从实际业务角度来看对于一个用户量大、登录态要求高的系统双token方案带来的收益是明显大于成本的。如果你的项目只是内部工具、十几个用户单token完全够用没必要为了双token而双token。1.3 整体认证流程梳理在设计双token方案时我把整个认证流程拆成了四个阶段。第一阶段是登录阶段。用户提交账号密码或者验证码服务端校验通过后生成access token和refresh token把refresh token存入Redis并设置过期时间然后返回给前端。同时服务端会根据业务需要把用户的基础信息用户ID、昵称、角色等放入access token中。第二阶段是正常访问阶段。前端在请求时携带access token后端的过滤器或拦截器校验token的签名、过期时间然后解析出用户ID放行请求。这一阶段服务端不查Redis依靠的就是JWT无状态的特性能扛住高并发。第三阶段是续签阶段。access token即将过期或已经过期时前端携带refresh token请求刷新接口。服务端收到refresh token后先校验签名和有效期再从Redis里查这个refresh token是否存在、是否已匹配当前用户如果都通过就生成新的access token返回也可以同时轮换refresh token。第四阶段是注销阶段。用户点击退出登录前端把access token和refresh token都丢弃服务端把Redis中的refresh token删除这样这个用户的登录态就彻底失效了。如果以后再拿到旧的access token也会在解码后检查到用户状态异常。整个流程的本质是access token负责“少打扰用户”refresh token负责“在安全的前提下恢复访问能力”服务端只维护refresh token的状态就同时兼顾了无状态和高可控性。2. 项目实现JWT双token的核心代码2.1 工程配置与依赖引入我们项目用的是Spring Boot 2.7 JDK 1.8JWT库选择了Java生态中最常用的jjwt。这里有一个版本选择的细节jjwt从0.11.x开始把API拆成了jjwt-api、jjwt-impl和jjwt-jackson三个包很多老项目还在用0.9.x版本那个版本的API虽然简单但存在一些安全问题。我们最终选了0.11.5。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我在第一次集成时踩过一个坑只引入了jjwt-api和jjwt-impl没有引入jjwt-jackson结果运行时直接报错说找不到合适的序列化器。因为jjwt在解析和生成token时需要对payload中的JSON数据进行序列化和反序列化如果你项目里正好用了JacksonSpring Boot默认就有就必须引入jjwt-jackson它会把Jackson作为底层的JSON处理器。另外项目可能已经有了全局的配置类我新建了一个JwtProperties类来存放token相关配置这样后续调整参数不需要改代码Data ConfigurationProperties(prefix jwt) Component public class JwtProperties { /** * 密钥长度至少32字节 */ private String secret; /** * access token过期时间单位秒默认30分钟 */ private Long accessTokenExpire 1800L; /** * refresh token过期时间单位秒默认7天 */ private Long refreshTokenExpire 604800L; /** * 签发者 */ private String issuer my-project; }对应application.yml里的配置jwt: secret: your-secret-key-must-be-at-least-32-bytes-long access-token-expire: 1800 refresh-token-expire: 604800需要特别提醒的是secret不能直接在代码里写死也不能用太短的字符串。HS256算法要求密钥长度至少256位也就是32字节否则jjwt会直接抛出WeakKeyException。实际项目中这个值应该放到配置中心或者环境变量里并且定期轮换。2.2 Token工具类的设计与实现我封装了一个JwtTokenProvider类把所有token生成、解析、校验的逻辑放在一起。这样控制层和业务层不会到处散落JWT相关的代码后续换算法、改有效期也只需要改这一个类。Component RequiredArgsConstructor public class JwtTokenProvider { private final JwtProperties jwtProperties; private SecretKey getSigningKey() { byte[] keyBytes jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8); return Keys.hmacShaKeyFor(keyBytes); } public String createAccessToken(Long userId, String username, ListString roles) { Date now new Date(); Date expiryDate new Date(now.getTime() jwtProperties.getAccessTokenExpire() * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .claim(roles, roles) .setIssuer(jwtProperties.getIssuer()) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } public String createRefreshToken(Long userId) { Date now new Date(); Date expiryDate new Date(now.getTime() jwtProperties.getRefreshTokenExpire() * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(tokenType, refresh) .setIssuer(jwtProperties.getIssuer()) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); } public boolean validateToken(String token) { try { parseToken(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } public Long getUserIdFromToken(String token) { Claims claims parseToken(token); return Long.valueOf(claims.getSubject()); } }这里有三个设计上的细节值得说明。第一个细节我在access token的claim里放入了username和roles而refresh token里只放了userId和tokenType。这样做的原因是access token会被频繁解析和传递把用户信息放进去可以避免每次请求都查数据库但refresh token只用于续签没必要携带过多信息而且claim越少token越短泄露后暴露的信息也越少。第二个细节我在refresh token里加了一个“tokenType: refresh”的claim。这看起来是个小动作实际作用很大。它可以在校验时区分token类型防止有人把一个access token当作refresh token提交到刷新接口。虽然access token的有效期比refresh token短但理论上在access token还没过期的时候它是可以通过签名校验的如果不区分类型会出现用access token成功换取新token的怪事。第三个细节过期时间的计算我统一用毫秒值做加减。如果你在代码里看到expiryDate now expire * 1000这其实是一个很容易忽略的陷阱。expire的单位是秒比如1800秒就是30分钟但Java的Date.getTime()返回的是毫秒所以必须乘以1000。我见过有人配置了1800结果实际token的过期时间只有1.8秒用户刚登录就提示过期了。调这种Bug的时候真的会怀疑人生。2.3 登录接口的完整实现登录接口的逻辑不复杂接收用户名密码校验通过后生成两个token把refresh token存到Redis然后返回。但实际操作中校验逻辑、错误提示、并发登录处理都是很容易出问题的地方。RestController RequestMapping(/api/auth) RequiredArgsConstructor public class AuthController { private final AuthenticationManager authenticationManager; private final JwtTokenProvider tokenProvider; private final RefreshTokenService refreshTokenService; private final UserDetailsService userDetailsService; PostMapping(/login) public ResponseEntityLoginResponse login(RequestBody LoginRequest loginRequest) { // 1. 认证用户名密码 Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); SecurityContextHolder.getContext().setAuthentication(authentication); // 2. 从认证结果中获取用户信息 UserPrincipal user (UserPrincipal) authentication.getPrincipal(); // 3. 生成access token和refresh token String accessToken tokenProvider.createAccessToken( user.getId(), user.getUsername(), user.getRoles()); String refreshToken tokenProvider.createRefreshToken(user.getId()); // 4. refresh token存入Redis维持7天有效 refreshTokenService.saveRefreshToken(user.getId(), refreshToken); // 5. 返回响应 return ResponseEntity.ok(new LoginResponse(accessToken, refreshToken)); } }这里需要重点说一下AuthenticationManager的用法。在Spring Security的默认配置下登录时需要构造一个UsernamePasswordAuthenticationToken然后调用authenticationManager.authenticate()它会走UserDetailsService去加载用户信息再用PasswordEncoder校验密码。很多初学者喜欢自己写密码比对逻辑但在Spring Security体系内我更推荐用AuthenticationManager因为这样能保持认证逻辑统一后续接入OAuth2、LDAP等认证方式时改动成本会小很多。RefreshTokenService的职责非常单一保存refresh token到Redis、根据token查询、删除。它的代码大概是这样的Service RequiredArgsConstructor public class RefreshTokenService { private static final String REFRESH_TOKEN_KEY_PREFIX auth:refresh:; private final StringRedisTemplate redisTemplate; public void saveRefreshToken(Long userId, String refreshToken) { // 同一个用户只保留一个有效的refresh token实现单点登录效果 Duration expire Duration.ofSeconds(604800L); redisTemplate.opsForValue().set( REFRESH_TOKEN_KEY_PREFIX userId, refreshToken, expire ); } public boolean isRefreshTokenValid(Long userId, String refreshToken) { String storedToken redisTemplate.opsForValue().get(REFRESH_TOKEN_KEY_PREFIX userId); return refreshToken ! null refreshToken.equals(storedToken); } public void deleteRefreshToken(Long userId) { redisTemplate.delete(REFRESH_TOKEN_KEY_PREFIX userId); } }这里有一个重要的设计决策我用Redis保存refresh token时key是用户IDvalue是refresh token本身。这意味着同一个用户同一时间只能有一个有效的refresh token新的登录会让旧的refresh token失效——这就是“单点登录”的逻辑。如果你的产品允许一个账号多端同时在线redis的key就不能简单用userId要改成userId 设备id或者session id来区分。2.4 拦截器与鉴权链路双token方案中服务端不需要在每台机器上验证refresh token但它仍然需要在每个需要鉴权的请求里校验access token。这里我用的是OncePerRequestFilter它继承自Spring Security的Filter能保证在每个请求中只执行一次。Component RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; private final UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (token ! null tokenProvider.validateToken(token)) { Claims claims tokenProvider.parseToken(token); // 只认access token防止refresh token被当作access token使用 if (!refresh.equals(claims.get(tokenType))) { String username (String) claims.get(username); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearerToken request.getHeader(Authorization); if (StringUtils.hasText(bearerToken) bearerToken.startsWith(Bearer )) { return bearerToken.substring(7); } return null; } }这个类的设计有几个需要琢磨的点。第一个点为什么校验access token之后还要重新查询UserDetailsService有人会觉得JWT本身就是可信的直接把它里面的username和roles放进去不就行了吗理论上可以但这样做的风险是如果用户被删除了、角色被修改了token在有效期内依然携带旧信息。我们在业务上是无法容忍一个被删除的用户还能继续访问接口15分钟的所以每次请求都会用userId去加载一次用户信息。代价是多一次数据库查询或者Redis缓存查询在绝大多数业务场景下完全可接受。第二个点刷新接口本身不应该被拦截器拦截因为前端在access token过期之后需要一个不受保护的入口来换取新token。我们在SecurityConfig里放行了/api/auth/refresh和/api/auth/login其他接口统一进入鉴权流程。第三个点关于SecurityContextHolder的清理问题。在我的interceptor逻辑里如果token为空或校验失败我没有主动把这些异常抛出去而是交给后续的Spring Security过滤器去处理。实际上更严谨的做法是在filter里直接写异常响应并设置正确的HTTP状态码——比如token过期时返回401而不是让请求一路走到业务代码里才报错。这个我在第4部分会展开讲。2.5 续签接口与Refresh Token轮换策略刷新接口是整个双token方案的核心。它的逻辑是接收refresh token校验合法性生成新的access token可选地生成新的refresh token然后返回。这个接口的写法看似简单实际里面藏着很多细节。PostMapping(/refresh) public ResponseEntity? refresh(RequestBody RefreshTokenRequest request) { String refreshToken request.getRefreshToken(); if (!tokenProvider.validateToken(refreshToken)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } Claims claims tokenProvider.parseToken(refreshToken); if (!refresh.equals(claims.get(tokenType))) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } Long userId Long.valueOf(claims.getSubject()); // 检查Redis中的refresh token是否仍然有效且匹配 if (!refreshTokenService.isRefreshTokenValid(userId, refreshToken)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } // 生成新的token String newAccessToken tokenProvider.createAccessToken(userId, (String) refreshTokenService.getUsernameByUserId(userId), null); String newRefreshToken tokenProvider.createRefreshToken(userId); // 让旧refresh token失效轮换 refreshTokenService.saveRefreshToken(userId, newRefreshToken); return ResponseEntity.ok(new RefreshTokenResponse(newAccessToken, newRefreshToken)); }这里我采用了“refresh token轮换”策略也就是每次刷新不仅生成新的access token也生成新的refresh token同时让旧的refresh token在Redis中失效。这样做的好处是如果某个refresh token被截获它只会被使用一次攻击者无法用同一个refresh token反复续期。这也是目前社区比较推荐的做法。但轮换策略有一个必须警惕的风险如果前端在并发场景下同时发起了多个刷新请求比如前端有两个页面同时发现token过期各自用同一个refresh token调用了刷新接口那么第一个请求会成功并生成新token第二个请求会因为Redis中旧的refresh token已被删除而失败。也就是说你的前端必须做刷新请求的“单例化”——常见做法是把刷新接口封装成一个Promise在刷新过程中所有并发的业务请求都等待同一个刷新Promise完成。还有一个容易被忽略的点续签接口返回响应时前端应该更新本地存储的access token和refresh token。如果前端只更新了access token下次刷新时还是用旧的refresh token那么这个用户会突然被迫下线。这个问题在联调时很容易被漏掉因为看起来“登录了”和“能获取到数据”都正常等你在线上跑了几十分钟就发现所有用户都掉线了。3. 安全细节与风险控制3.1 密钥管理与算法选择JWT的安全性很大程度上取决于签名算法和密钥管理。HS256和RS256是现在最常见的两种选择我用一张表来对比一下。对比项HS256RS256密钥类型对称密钥同一个secret用于签名和验签非对称密钥私钥签名公钥验签性能快相对慢但可接受密钥存储位置各服务共享同一个secret私钥只在认证服务其他服务只持有公钥适用场景单服务或内部服务之间互信微服务架构、第三方集成如果你的项目还是传统的单体应用用HS256完全够用注意secret要足够长且随机。但如果你做微服务不同服务之间都要验签用RS256更合适因为签名服务只需把公钥给其他服务即使某个服务被攻破也不会泄露签名密钥。有些项目还支持JWKJSON Web Key动态获取公钥这个可以做成定时轮换进一步提升安全性。密钥轮换也是必须考虑的。假设运维发现某个环境中secret可能泄露了你需要能快速让所有旧的token失效。做法是给token增加一个kidkey id在解析token时根据kid找到对应的key这样即使旧key泄露只要你把新key配上去并且拒签旧kid的token就能实现“一刀切”式失效。不过这个方案需要额外的key管理机制一般中小项目放在配置中心就够了。3.2 过期时间设计的平衡点access token和refresh token的过期时间需要根据业务场景仔细权衡不能盲目照抄别人的配置。我整理了几个常见的配置组合供参考。如果需要更安全的场景比如支付系统、管理后台建议access token设15分钟refresh token设12小时甚至可以更短。此时用户至少每12小时重新登录一次安全性最高但体验略差。常规Web应用比如后台管理系统、内部平台access token设30分钟到1小时refresh token设7天基本能满足大多数用户习惯。用户一周内不需要重新登录但token泄露的窗口也相对可控。对移动端Appaccess token可以设置2小时refresh token设置30天因为手机App的使用频率高如果频繁要求输入密码用户很容易流失。但refresh token过长也意味着攻击者如果有机会截获refresh token能持续访问一个月所以App端通常还需要配合设备管理、生物识别等方式来增强安全性。这里我想多说一句不要为了“显得专业”就把access token设得很短比如10分钟结果用户每隔10分钟就卡一次转圈前端还要处理并发刷新的逻辑。过期时间设置的最终标准是“业务可接受的安全风险”和“用户可接受的打扰频率”之间的平衡没有放之四海而皆准的答案。3.3 注销与踢人下线的实现双token方案里注销做起来比单token直观得多。核心思想就是删除Redis中的refresh token让用户即使持有旧的refresh token也换不到新的access token。很多团队的实现里注销接口只是清空了前端本地的token服务端什么都不做。这样是存在隐患的如果用户的access token已经在别的地方被缓存了或者前端本地token被截图/复制服务端根本无法让你登出。更安全的做法是注销时同时把refresh token从Redis中删除并在Redis里记录这个用户的“下线时间”或“注销状态”。之后即使有人拿着这个用户之前的access token来访问服务端在解析token后还要顺便检查一下这个userId是否还在黑名单里如果在就直接拒绝。如果要做“踢人下线”或者“管理员封禁用户”逻辑也很简单。管理员操作时删除该用户的所有refresh token同时把用户状态设为禁用。前端下一次请求刷新接口时refresh token已经不存在了自然无法换取新的token用户就被迫下线了。需要注意的是如果你删除了所有refresh token但用户的access token还有效比如剩最后5分钟用户在这5分钟里依然能访问接口。要彻底让access token立刻失效你需要在鉴权过滤器里同时检查用户状态。这也是为什么我在2.4节说解析token后还要查一次用户信息。如果你做了用户状态检查即使access token没过期也能马上踢掉被禁用的用户。3.4 多端登录与并发场景的处理多端登录同一个账号在手机和电脑同时在线和单点登录一台设备登录后另一台设备被挤下线是两种常见的业务需求它们的实现思路完全不同。如果你希望支持多端在线refresh token的存储key就不能只有userId而要包含一个设备标识。比如手机端登录时生成一个sessionId电脑端登录时生成另一个sessionIdRedis的key用auth:refresh:{userId}:{sessionId}。每个端各自保存自己的refresh token互不影响。前端需要把sessionId也存下来后续请求顺便带上它方便服务端识别。如果你希望实现“后登录的挤掉先登录的”可以简单地保持“key是userId、value是refresh token”的逻辑新登录会覆盖旧token。不过这个策略需要产品经理确认清楚因为很多时候“多端在线”才是用户默认期望强行单点登录会招致投诉。并发场景是另一个需要重点考虑的问题。用户可能在多个标签页打开你的网站这些页面的access token同时过期然后同时调刷新接口。如果没有处理就会有一批请求因为refresh token已被轮换而失败。前端一般用“单例刷新”来解决用一个变量保存刷新Promise所有需要等token刷新的逻辑都去await这个Promise而不是各自重新发起刷新。后端层面你还可以在刷新接口加分布式锁确保同一个userId的刷新请求串行处理避免极端情况下两个请求同时读到旧token然后都尝试写入新token。3.5 token泄露的应急响应虽然我们把access token的有效期控制得很短但谁也无法保证token绝对不泄露。我在项目中建立了一套简单的应急响应机制供大家参考。第一步是快速分析泄露面。通过日志找出泄露的token对应的userId、签发时间、最后使用时间确认影响范围。如果是个人账号泄露可能只需要定向处理如果是系统级泄露比如某个第三方SDK把Authorization头打印到了日志里那就要考虑全量轮换密钥。第二步是立即撤销。对于refresh token直接删除Redis中对应的记录对于access token短期的做法是等它过期长期的兜底方案是维护一个黑名单把相关userId的token加入黑名单并在过滤器里拒绝。如果泄露面很大直接轮换jwt的secret让所有旧token立刻失效全站用户重新登录。第三步是从源头上堵住漏洞。最常用的做法是审查日志打印确保任何地方都不打印Authorization头在网关层给响应头加上Cache-Control: no-store防止浏览器缓存了包含token的响应另外前后端都要用HTTPS这是所有方案的底线。4. 常见问题与排查技巧实录4.1 token过期边界问题实际开发中最容易出错的是token过期时间的边界判断。比如access token的过期时间是30分钟假设用户在时间点T签发token那么T1800秒时这个token已经过期。但是因为服务器时钟和客户端时钟可能存在偏差如果在临界点附近发送请求可能客户端认为token还有效服务端已经认为过期了。这个问题在标准HTTP请求里不太明显但在文件上传、长轮询、WebSocket等场景里会爆发。比如一个文件上传耗时较长到达后端时access token已经过期了用户就莫名其妙收到401。解决办法有两个方向一是适当放宽access token的过期时间给业务操作留出缓冲二是在前端拦截到401时自动刷新token并重发一次请求在用户无感知的情况下完成恢复。后者我在生产项目中用的是最顺手的方案。还有一个坑是Redis的key过期时间和refresh token的过期时间不一致。假设refresh token的有效期是7天但你在Redis里设置的是5天过期那么第5天到第7天之间用户拿着一个签名上仍然有效的refresh token去刷新会得到“刷新失败”。反过来如果Redis过期时间比token的签名有效期还长又会造成session管理上的混乱。我的建议是Redis的TTL和refresh token的有效期保持一致并且两边都用同一个配置源避免手工维护两套数字。4.2 时钟与时间戳相关BugJWT里有iat签发时间和exp过期时间这两个字段依赖服务器的系统时钟。如果服务器之间时间不同步尤其是在多实例部署时可能出现在A机器签发的token在B机器验签时被判定为过期。这种现象在时间相差很小的时候很少被察觉但一旦某台服务器的时钟偏差超过数分钟就会导致间歇性登录失败。排查这种问题时我常用的命令是先同步服务器时间再看各实例的系统当前时间是否一致。如果公司内部有NTP服务器配置好时间同步没有的话用国内公共NTP源也行。另外代码层面我给jjwt配置了一个时钟偏移量允许一定范围内的时钟差异Jwts.parserBuilder() .setSigningKey(getSigningKey()) .setClock(() - new Date()) .build() .parseClaimsJws(token);这个配置的作用是把“当前时间”通过函数注入到校验逻辑里方便在测试时模拟时间差但生产上一旦发现时间偏差还是应该优先修系统时钟而不是无限放宽偏移量。在测试环境里我遇到过一次特别迷惑的情况后端返回的token看着是有效的但jwt.io解析出来的exp还是昨天。后来排查才发现是测试服务器上没有配置时区导致JVM解析时间字符串时用了UTC而业务系统用的是东八区最终exp计算错了。所以配置好统一的时区建议容器和JVM都使用Asia/Shanghai是开发JWT功能前就应该做好的基础工作。4.3 并发续签与重复请求问题并发续签问题我在前面提到了这里用一个具体的日志场景来说明。上线第一天我们收到很多用户反馈页面操作到一半突然跳登录页查看日志发现大量的401记录然后紧接着刷新接口也返回401。最初我以为是refresh token写了Bug后来复现才发现这个用户开了三个标签页三个页面几乎同时检测到token过期同时发了三个刷新请求。第一个请求刷新成功并轮换了refresh token后面两个请求带着旧的refresh token来刷新自然就失败。前端侧的解决方案是把刷新接口做成交互锁。大致思路是这样的定义一个变量refreshPromise第一次调用刷新接口时把它赋值给refreshPromise后续的刷新请求直接复用这个Promise无论成功还是失败在结束时把refreshPromise置空。这样能保证任意时刻只有一个刷新请求在途其他请求排队拿新token。后端侧的兜底方案是给刷新接口加一个基于userId的分布式锁。这里要注意不能用同步代码块或者单机锁因为多实例部署时锁不会共享。用Redis的setnx命令可以实现一个简单的分布式锁在刷新逻辑前后加锁和解锁。虽然多了一层Redis交互但刷新接口本身调用频率不高这点性能开销可以忽略。4.4 调试工具与在线解析问题调试JWT的时候很多同事第一反应是打开jwt.io把token粘进去看解析结果。jwt.io本身很好用但有几个需要警惕的地方它毕竟是第三方网站把生产环境的token粘进去存在泄露风险。我在团队里定的规矩是只允许在测试环境把token粘到jwt.io生产环境的token一律在本地用写好的测试接口来解析或者放到内网部署的调试工具里。除此之外Java开发中用好调试工具能节省大量时间。我在项目中顺手写了一个小的JwtDebugController只在测试环境暴露用来快速解析token内容RestController RequestMapping(/dev) public class JwtDebugController { private final JwtTokenProvider tokenProvider; GetMapping(/parse) public MapString, Object parse(RequestParam String token) { Claims claims tokenProvider.parseToken(token); return new HashMap(claims); } }这个Controller的最大价值在于它可以直接看到token里的过期时间、签发时间、用户ID、角色列表不用反复去jwt.io上粘贴复制。线上环境记得把它关掉否则等于给攻击者开了个大后门。4.5 其他容易踩的坑与排查速查表我最后整理一个速查表把双token开发过程中最常见的坑和排查方向列出来方便大家遇到问题时快速对照。症状可能原因解决思路系统初始化时报WeakKeyExceptionSecret长度不足32字节换一个足够长的secret至少32字节登录后立即提示未认证access token的exp计算用的单位不对检查过期时间是否乘以1000某个用户频繁被踢下线客户端使用了旧refresh token刷新检查前端是否更新本地refresh token刷新接口偶发401多个请求并发抢用同一个refresh token前端加单例刷新后端加分布式锁用户注销后仍能访问只删了Redis没做用户状态校验过滤器里增加用户状态检查多实例部署后token失效频繁服务器时间不同步配置NTP统一时间允许时钟偏移用access token调刷新接口成功没有校验token类型校验载荷中的tokenType字段使用jwt.io解析生产token后泄露习惯性粘贴到第三方网站收口工具禁止生产token粘贴到jwt.io在整个双token登录认证的落地过程中我最深的体会是JWT本身只是一种格式规范真正的复杂性在于和业务结合后产生的各种边界情况。双token方案相比单token安全性确实上了一个台阶但它不是靠某一个接口或者某一个库就能解决的需要前后端配合、配置合理、异常场景覆盖到位。如果你正打算把现有的登录模块升级成双token建议先画出完整的时序图把登录、访问、续签、注销、异常处理这五条核心链路理清楚再动手写代码。这样踩的坑会少很多上线的时候心里也有底。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询