Spring Boot + JWT实现双令牌续期,彻底解决Token过期难题

在前后端分离架构中,Token过期一直是开发者面临的痛点。用户正操作着突然被强制退出?体验极差!今天我将带你用双令牌续期机制彻底解决这个问题,让你的应用体验丝般顺滑。

一、为什么单Token方案会让人崩溃?

传统方案的问题场景:

// 用户正在填写重大表单时...
try {
    // 突然Token过期,操作失败!
    if (isTokenExpired(token)) {
        throw new TokenExpiredException("请重新登录");
    }
} catch (TokenExpiredException e) {
    // 用户填了一半的数据全没了!
    redirectToLogin();
}

用户心声: “我就上了个厕所,回来工作全白干了!”

二、双令牌机制:Access Token + Refresh Token

核心原理图解

登录成功
    ↓
返回: Access Token(短效) + Refresh Token(长效)
    ↓
Access Token过期 (如30分钟)
    ↓
使用Refresh Token静默刷新
    ↓
返回新的Access Token → 用户无感知继续操作

令牌生命周期对比

令牌类型

有效期

用途

安全风险

Access Token

15-30分钟

接口访问

较高

Refresh Token

7天

刷新Access Token

较低

三、Spring Boot + JWT双令牌完整实现

1. 核心依赖配置

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

2. JWT工具类封装

@Component
public class JwtTokenProvider {
    
    // 从配置文件中读取,实际项目中应该使用更安全的存储方式
    @Value("${jwt.secret:mySecretKey}")
    private String jwtSecret;
    
    @Value("${jwt.access-token-expiration:1800000}") // 30分钟
    private long accessTokenExpiration;
    
    @Value("${jwt.refresh-token-expiration:604800000}") // 7天
    private long refreshTokenExpiration;
    
    // 生成Access Token
    public String generateAccessToken(UserDetails userDetails) {
        return buildToken(userDetails, accessTokenExpiration);
    }
    
    // 生成Refresh Token
    public String generateRefreshToken(UserDetails userDetails) {
        return buildToken(userDetails, refreshTokenExpiration);
    }
    
    private String buildToken(UserDetails userDetails, long expiration) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("roles", userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()));
        
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(SignatureAlgorithm.HS256, jwtSecret)
                .compact();
    }
    
    // 验证Token是否有效
    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
    
    // 从Token中获取用户名
    public String getUsernameFromToken(String token) {
        return Jwts.parser()
                .setSigningKey(jwtSecret)
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }
    
    // 检查Token是否即将过期(用于提前刷新)
    public boolean isTokenExpiringSoon(String token, long threshold) {
        try {
            Date expiration = Jwts.parser()
                    .setSigningKey(jwtSecret)
                    .parseClaimsJws(token)
                    .getBody()
                    .getExpiration();
            return expiration.getTime() - System.currentTimeMillis() <= threshold;
        } catch (Exception e) {
            return true;
        }
    }
}

3. 登录接口:返回双Token

@RestController
@RequestMapping("/api/auth")
public class AuthController {
    
    @Autowired
    private JwtTokenProvider tokenProvider;
    
    @Autowired
    private AuthenticationManager authenticationManager;
    
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
        try {
            // 1. 身份验证
            Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                    loginRequest.getUsername(),
                    loginRequest.getPassword()
                )
            );
            
            // 2. 生成双令牌
            String accessToken = tokenProvider.generateAccessToken(
                (UserDetails) authentication.getPrincipal());
            String refreshToken = tokenProvider.generateRefreshToken(
                (UserDetails) authentication.getPrincipal());
            
            // 3. 返回令牌(实际项目中Refresh Token提议存数据库)
            return ResponseEntity.ok(new JwtResponse(
                accessToken, 
                refreshToken, 
                "Bearer", 
                tokenProvider.getExpirationTime(accessToken)
            ));
            
        } catch (AuthenticationException e) {
            return ResponseEntity.status(401).body("用户名或密码错误");
        }
    }
    
    // DTO类
    @Data
    public static class LoginRequest {
        private String username;
        private String password;
    }
    
    @Data
    @AllArgsConstructor
    public static class JwtResponse {
        private String accessToken;
        private String refreshToken;
        private String tokenType;
        private Long expiresIn;
    }
}

4. Token刷新接口

@PostMapping("/refresh")
public ResponseEntity<?> refreshToken(@RequestBody RefreshTokenRequest request) {
    try {
        String refreshToken = request.getRefreshToken();
        
        // 验证Refresh Token有效性
        if (!tokenProvider.validateToken(refreshToken)) {
            return ResponseEntity.status(401).body("Refresh Token已失效");
        }
        
        // 从Refresh Token中获取用户信息
        String username = tokenProvider.getUsernameFromToken(refreshToken);
        
        // 模拟从数据库加载用户信息(实际项目中需要真实实现)
        UserDetails userDetails = loadUserByUsername(username);
        
        // 生成新的Access Token
        String newAccessToken = tokenProvider.generateAccessToken(userDetails);
        
        return ResponseEntity.ok(new RefreshTokenResponse(
            newAccessToken, 
            "Bearer", 
            tokenProvider.getExpirationTime(newAccessToken)
        ));
        
    } catch (Exception e) {
        return ResponseEntity.status(401).body("Token刷新失败");
    }
}

@Data
public static class RefreshTokenRequest {
    private String refreshToken;
}

@Data
@AllArgsConstructor
public static class RefreshTokenResponse {
    private String accessToken;
    private String tokenType;
    private Long expiresIn;
}

5. 前端Token管理策略

class TokenManager {
    constructor() {
        this.accessToken = localStorage.getItem('accessToken');
        this.refreshToken = localStorage.getItem('refreshToken');
        this.isRefreshing = false;
        this.refreshSubscribers = [];
    }
    
    // 请求拦截器 - 自动添加Token
    async requestInterceptor(config) {
        if (this.accessToken) {
            // 检查Access Token是否即将过期(提前2分钟刷新)
            if (this.isTokenExpiringSoon(this.accessToken)) {
                await this.refreshTokens();
            }
            config.headers.Authorization = `Bearer ${this.accessToken}`;
        }
        return config;
    }
    
    // 响应拦截器 - 处理Token过期
    async responseInterceptor(error) {
        const { config, response } = error;
        
        if (response.status === 401 && !config._retry) {
            config._retry = true;
            
            try {
                await this.refreshTokens();
                config.headers.Authorization = `Bearer ${this.accessToken}`;
                return axios(config);
            } catch (refreshError) {
                this.clearTokens();
                window.location.href = '/login';
                return Promise.reject(refreshError);
            }
        }
        return Promise.reject(error);
    }
    
    // 刷新Token
    async refreshTokens() {
        if (this.isRefreshing) {
            return new Promise(resolve => {
                this.refreshSubscribers.push(resolve);
            });
        }
        
        this.isRefreshing = true;
        
        try {
            const response = await axios.post('/api/auth/refresh', {
                refreshToken: this.refreshToken
            });
            
            const { accessToken } = response.data;
            this.setAccessToken(accessToken);
            
            this.refreshSubscribers.forEach(callback => callback());
            this.refreshSubscribers = [];
            
        } catch (error) {
            this.clearTokens();
            throw error;
        } finally {
            this.isRefreshing = false;
        }
    }
    
    setAccessToken(token) {
        this.accessToken = token;
        localStorage.setItem('accessToken', token);
    }
    
    clearTokens() {
        this.accessToken = null;
        this.refreshToken = null;
        localStorage.removeItem('accessToken');
        localStorage.removeItem('refreshToken');
    }
    
    isTokenExpiringSoon(token) {
        const payload = JSON.parse(atob(token.split('.')[1]));
        const exp = payload.exp * 1000;
        const now = Date.now();
        return (exp - now) < 2 * 60 * 1000; // 2分钟
    }
}

6. Spring Security配置

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Autowired
    private JwtTokenProvider jwtTokenProvider;
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/api/auth/**").permitAll()
            .anyRequest().authenticated()
            .and()
            .apply(new JwtConfigurer(jwtTokenProvider));
    }
    
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

// JWT过滤器配置
public class JwtConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    
    private final JwtTokenProvider jwtTokenProvider;
    
    public JwtConfigurer(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }
    
    @Override
    public void configure(HttpSecurity http) {
        JwtTokenFilter customFilter = new JwtTokenFilter(jwtTokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

// JWT Token过滤器
public class JwtTokenFilter extends OncePerRequestFilter {
    
    private final JwtTokenProvider jwtTokenProvider;
    
    public JwtTokenFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                  HttpServletResponse response, 
                                  FilterChain filterChain) throws ServletException, IOException {
        
        String token = resolveToken(request);
        
        if (token != null && jwtTokenProvider.validateToken(token)) {
            String username = jwtTokenProvider.getUsernameFromToken(token);
            
            // 模拟从数据库加载用户权限(实际项目中需要真实实现)
            UserDetails userDetails = loadUserByUsername(username);
            
            UsernamePasswordAuthenticationToken auth = 
                new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
            auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        
        filterChain.doFilter(request, response);
    }
    
    private String resolveToken(HttpServletRequest req) {
        String bearerToken = req.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

四、高级优化策略

1. Refresh Token轮换机制

@Service
public class AdvancedTokenService {
    
    @Autowired
    private JwtTokenProvider tokenProvider;
    
    // Refresh Token轮换:每次使用后生成新的Refresh Token
    public RefreshTokenResponse refreshWithRotation(String oldRefreshToken) {
        // 验证旧的Refresh Token
        if (!tokenProvider.validateToken(oldRefreshToken)) {
            throw new SecurityException("无效的Refresh Token");
        }
        
        String username = tokenProvider.getUsernameFromToken(oldRefreshToken);
        UserDetails userDetails = loadUserByUsername(username);
        
        // 生成新的双令牌
        String newAccessToken = tokenProvider.generateAccessToken(userDetails);
        String newRefreshToken = tokenProvider.generateRefreshToken(userDetails);
        
        // 使旧的Refresh Token失效(在实际项目中,可以将其加入黑名单)
        invalidateRefreshToken(oldRefreshToken);
        
        return new RefreshTokenResponse(newAccessToken, newRefreshToken);
    }
    
    private void invalidateRefreshToken(String token) {
        // 实际项目中:将token加入Redis黑名单或从数据库删除
        // 这里简单模拟
        log.info("Refresh Token已失效: {}", token);
    }
}

2. 并发请求Token刷新优化

java

@Component
public class TokenRefreshSynchronizer {
    
    private final Map<String, CompletableFuture<String>> refreshInProgress = new ConcurrentHashMap<>();
    
    public CompletableFuture<String> refreshTokens(String refreshToken, String username) {
        // 如果已经在刷新,返回同一个Future
        if (refreshInProgress.containsKey(username)) {
            return refreshInProgress.get(username);
        }
        
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            try {
                // 模拟刷新操作
                Thread.sleep(1000);
                return "new-access-token";
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                refreshInProgress.remove(username);
            }
        });
        
        refreshInProgress.put(username, future);
        return future;
    }
}

五、安全最佳实践

1. Token存储策略对比

public enum TokenStorageStrategy {
    // 内存存储 - 简单但不安全
    MEMORY {
        public void storeRefreshToken(String token, String username) {
            // 简单的内存存储
        }
    },
    
    // Redis存储 - 推荐方案
    REDIS {
        public void storeRefreshToken(String token, String username) {
            // 存储到Redis,可设置自动过期
            // redisTemplate.opsForValue().set(key, token, duration);
        }
    },
    
    // 数据库存储 - 功能最全
    DATABASE {
        public void storeRefreshToken(String token, String username) {
            // 存储到数据库,可记录详细日志
            // refreshTokenRepository.save(new RefreshToken(token, username));
        }
    };
    
    public abstract void storeRefreshToken(String token, String username);
}

2. 安全配置检查清单

jwt:
  security:
    checklist:
      - 使用足够长的密钥(至少32字符)
      - Access Token有效期不超过30分钟
      - Refresh Token启用轮换机制
      - HTTPS传输令牌
      - 实现Token黑名单机制
      - 记录Token使用日志
      - 限制Refresh Token使用频率

六、实战测试案例

@SpringBootTest
class JwtAuthTest {
    
    @Test
    void testTokenRefreshFlow() {
        // 1. 用户登录获取双令牌
        TokenPair initialTokens = authService.login("user", "password");
        
        // 2. 使用Access Token访问受保护接口
        String result = protectedService.getData(initialTokens.getAccessToken());
        assertNotNull(result);
        
        // 3. 模拟Token过期
        String expiredToken = createExpiredToken();
        
        // 4. 使用Refresh Token刷新
        TokenPair newTokens = authService.refreshToken(initialTokens.getRefreshToken());
        
        // 5. 使用新Token继续访问
        String newResult = protectedService.getData(newTokens.getAccessToken());
        assertNotNull(newResult);
        
        // 验证用户会话未中断
        assertEquals(getCurrentUser(), "user");
    }
}

总结

通过双令牌续期机制,我们实现了:

用户体验提升 – 用户无需频繁重新登录
安全性保障 – Access Token短有效期减少风险
灵活控制 – 可随时使Refresh Token失效
性能优化 – 避免重复认证开销

核心要点记住:

  • Access Token负责日常接口访问(短效)
  • Refresh Token负责刷新Access Token(长效)
  • 前端自动检测Token过期并静默刷新
  • 后端实现安全的Token轮换机制

这套方案已在多个大型项目中验证,能有效解决Token过期带来的用户体验问题。赶紧在你的项目中实践起来吧!

© 版权声明

相关文章

6 条评论

  • 头像
    阿峰网络 读者

    继续加油💪

    无记录
    回复
  • 头像
    米果在线 读者

    向你学习👍

    无记录
    回复
  • 头像
    星星贩卖所 读者

    好思路💪

    无记录
    回复
  • 头像
    高翔 读者

    优秀💪

    无记录
    回复
  • 头像
    宇宙小猫酱_ 投稿者

    收藏了,感谢分享

    无记录
    回复
  • 头像
    金洪源粮油 读者

    大佬带带我👏

    无记录
    回复