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过期带来的用户体验问题。赶紧在你的项目中实践起来吧!
© 版权声明
文章版权归作者所有,未经允许请勿转载。

继续加油💪
向你学习👍
好思路💪
优秀💪
收藏了,感谢分享
大佬带带我👏