项目用 JWT 做认证,Token 有效期设了 2 小时。用户上午登录,下午用的时候突然被踢回登录页------Token 过期了。产品说"体验太差了,能不能别老让人重新登录"。
JWT 基本原理
css
Header.Payload.Signature
三段用 . 拼接,每段都是 Base64 编码:
- Header :算法类型(
{"alg": "HS256", "typ": "JWT"}) - Payload :用户信息 + 过期时间(
{"userId": 123, "exp": 1727000000}) - Signature :
HMACSHA256(header + payload, secret)签名,防篡改
服务端不存 session,只存一个 secret。验证的时候重新算一遍签名,对得上就信任 Payload 里的数据。
后端签发和验证(Java + Spring Boot)
java
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private long expiration; // 毫秒
public String generateToken(Long userId, String role) {
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("role", role)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}
public boolean isTokenExpired(String token) {
try {
Claims claims = parseToken(token);
return claims.getExpiration().before(new Date());
} catch (Exception e) {
return true;
}
}
}
拦截器校验
java
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Autowired
private JwtUtil jwtUtil;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) {
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
response.setStatus(401);
return false;
}
token = token.substring(7);
if (jwtUtil.isTokenExpired(token)) {
response.setStatus(401);
response.setHeader("Token-Expired", "true"); // 告诉前端是过期不是未登录
return false;
}
Claims claims = jwtUtil.parseToken(token);
request.setAttribute("userId", Long.parseLong(claims.getSubject()));
request.setAttribute("role", claims.get("role", String.class));
return true;
}
}
无感续期方案
用户不应该在正常使用过程中被踢下线。方案是双 Token:
- Access Token:短有效期(2 小时),用于 API 鉴权
- Refresh Token:长有效期(7 天),只用于换取新的 Access Token
java
// 登录时同时签发两个 Token
public LoginVO login(String username, String password) {
// 验证用户名密码...
String accessToken = jwtUtil.generateToken(user.getId(), user.getRole());
String refreshToken = jwtUtil.generateRefreshToken(user.getId());
// Refresh Token 存 Redis,7 天过期
redis.setex("refresh:" + user.getId(), 7 * 86400, refreshToken);
return new LoginVO(accessToken, refreshToken);
}
// 刷新接口
@GetMapping("/auth/refresh")
public Result<TokenVO> refresh(@RequestHeader("Refresh-Token") String refreshToken) {
Claims claims = jwtUtil.parseToken(refreshToken);
Long userId = Long.parseLong(claims.getSubject());
// 校验 Redis 里的 Refresh Token 是否一致(防止旧 Token 被重用)
String stored = redis.get("refresh:" + userId);
if (stored == null || !stored.equals(refreshToken)) {
return Result.error(401, "Refresh Token 无效");
}
// 签发新的 Access Token
String newAccessToken = jwtUtil.generateToken(userId, claims.get("role", String.class));
return Result.ok(new TokenVO(newAccessToken, refreshToken));
}
前端自动续期
js
// request.js
let isRefreshing = false
let pendingRequests = []
service.interceptors.response.use(
(response) => response,
async (error) => {
const status = error.response?.status
const isTokenExpired = error.response?.headers['token-expired'] === 'true'
if (status === 401 && isTokenExpired) {
if (isRefreshing) {
// 已经在刷新 Token 了,把请求挂起等待
return new Promise((resolve) => {
pendingRequests.push((token) => {
error.config.headers.Authorization = `Bearer ${token}`
resolve(service(error.config))
})
})
}
isRefreshing = true
try {
const refreshToken = localStorage.getItem('refreshToken')
const { data } = await authApi.refresh(refreshToken)
localStorage.setItem('token', data.accessToken)
// 重发所有挂起的请求
pendingRequests.forEach(cb => cb(data.accessToken))
pendingRequests = []
// 重发当前请求
error.config.headers.Authorization = `Bearer ${data.accessToken}`
return service(error.config)
} catch (e) {
// Refresh Token 也过期了,跳登录
localStorage.clear()
window.location.href = '/login'
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}
)
核心逻辑:Access Token 过期 → 自动用 Refresh Token 换新的 → 重发失败的请求。用户全程无感知。
安全注意事项
- Refresh Token 要存 Redis 并支持主动失效:用户改密码或登出时删掉 Redis 里的 Refresh Token
- Access Token 不要存敏感信息:JWT 的 Payload 只是 Base64 编码,任何人都能解码看到内容
- secret 要够长:至少 256 位,不然可以被暴力破解
- HTTPS 必须开:Token 在 Header 里传输,HTTP 明文会被中间人截获
总结
JWT 本身很简单,坑在续期策略。双 Token 方案(短 Access + 长 Refresh)+ 前端自动刷新 + 请求队列,可以做到用户无感续期。Refresh Token 存 Redis 支持主动失效,比纯 JWT 的无状态方案更安全。