【SpringBoot】最佳实践——JWT结合Redis实现双Token无感刷新

发布于:2025-03-16 ⋅ 阅读:(20) ⋅ 点赞:(0)

JWT概览

JWT概念

JWT是全称是JSON WEB TOKEN,是一个开放标准,用于将各方数据信息作为JSON格式进行对象传递,可以对数据进行可选的数字加密,可使用RSAECDSA进行公钥/私钥签名。JWT最常见的使用场景就是缓存当前用户登录信息,当用户登录成功之后,拿到JWT,之后用户的每一个请求在请求头携带上Authorization字段来辨别区分请求的用户信息。且不需要额外的资源开销。

JWT组成部分

JWT通常由一个头部(Header)、一个负载(Payload)和一个签名(Signature)三部分组成,这三部分之间用点(.)分隔。所以,一个完整的JWT看起来像这样:

xxxxx.yyyyy.zzzzz

下面我们来详细解析每一部分:

头部(header)

头部用于描述令牌的元数据,通常包含令牌的类型(即JWT)和所使用的签名算法(如HMAC SHA256)。

  • typ:表示令牌的类型,JWT令牌统一写为"JWT"。
  • alg:表示签名使用的算法,例如HMAC SHA256或RSA。

头部信息会被进行Base64编码,形成JWT的第一部分。

{  
  "typ": "JWT",  
  "alg": "HS256"  
}

负载(payload)

负载包含了JWT的声明,即传递的数据,这些数据通常包括用户信息和其他相关数据。声明有三种类型:注册的声明、公共的声明和私有的声明。

  • 注册的声明:这是一组预定义的声明,它们不是强制的,但是推荐使用,以提供一组有用的、可互操作的声明。如:iat(签发时间)、exp(过期时间)、aud(接收方)、sub(用户唯一标识)、jti(JWT唯一标识)等。
  • 公共的声明:可以定义任何名称,但应避免与注册的声明名称冲突。
  • 私有的声明:是提供者和消费者之间共同定义的声明。

负载同样会被Base64编码,形成JWT的第二部分。

{  
  "sub": "1234567890",  
  "name": "John Doe",  
  "jti": "unique-jwt-id",
  "admin": true  
}

签名(signature)

签名将头部和负载用指定的算法进行签名,验证JWT的真实性和完整性。当接收者收到JWT时,他们可以使用相同的算法和密钥(对于HMAC算法)或使用公钥(对于RSA或ECDSA算法)验证签名。如果两个签名匹配,那么JWT就是有效的。

签名的过程如下:

  • 先将Base64编码后的头部和负载数据用点号(.)连接起来。
  • 使用指定的签名算法(例如,HMAC SHA256、RSA、ECDSA)和密钥对连接后的字符串进行签名。
  • 将生成的签名部分进行Base64Url编码,形成JWT的第三部分。

签名部分也是经过Base64Url编码的,形成JWT的第三部分。

HMACSHA256(
    base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    secret)

注意:虽然Base64Url编码不是加密方式,但它可以确保JWT的字符串格式是紧凑的,并且容易在URL、POST参数或HTTP头部中传输。

技术方案设计

单点登录(SSO)

  • 单点登录(Single Sign-On, SSO) 是一种身份认证机制,允许用户通过一次登录即可访问多个相互信任的应用系统,而无需重复输入认证信息。

双Token机制

  • AccessToken
    • 短期有效(如30分钟),用于接口访问。
    • 客户端每次请求API时携带。
    • 不持久化存储,仅通过签名验证合法性。
  • RefreshToken
    • 用于获取新的Access Token,有效期长(如3天)。
    • 仅在刷新令牌时传输,不直接访问业务API。
    • 必须持久化存储(如Redis),服务端可主动使其失效。
  • 签名算法:使用RSA非对称加密算法,减少内存占用,防止篡改,并方便后续拓展子系统。

无感刷新Token

  • 客户端将由于AccesssToken过期失败的请求存储起来,携带RefreshToken成功刷新Token后,将存储的失败请求重新发起,以此达到用户无感的体验。
  • 服务端根据RefreshToken解析出userId和deviceId后,去Redis中查询存储的RefreshToken并进行比对,成功后生成新的AT和RT并返回

多端会话管理

  • 同一账号在不同设备登录时,为每个设备生成独立的RefreshToken。
  • Redis中以 userId:deviceId为键存储RefreshToken,过期时间设置为RefreshToken的过期时间。

废弃令牌移除

  • Redis中以 blacklist:token 为键存储AccessToken黑名单,键值对的过期时间设置为AccessToken的剩余有效期。
  • 直接删除Redis中的RefreshToken。

最佳实践

总体流程

JWT工具类

// JWT工具类
import io.jsonwebtoken.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;

@Component
public class JwtUtil {
    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.access.expiration}")
    private long accessExpiration;

    @Value("${jwt.refresh.expiration}")
    private long refreshExpiration;

    public String generateAccessToken(String username) {
        return Jwts.builder()
        .setSubject(username)
        .setIssuedAt(new Date())
        .setExpiration(new Date(System.currentTimeMillis() + accessExpiration))
        .signWith(SignatureAlgorithm.HS512, secret)
        .compact();
    }

    public String generateRefreshToken(String username) {
        return Jwts.builder()
        .setSubject(username)
        .setIssuedAt(new Date())
        .setExpiration(new Date(System.currentTimeMillis() + refreshExpiration))
        .signWith(SignatureAlgorithm.HS512, secret)
        .compact();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
            return true;
        } catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e) {
            return false;
        }
    }

    public String getUsernameFromToken(String token) {
        return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody().getSubject();
    }
}

Redis服务类

// Redis服务类
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;

@Service
public class RedisService {
    private final StringRedisTemplate redisTemplate;

    public RedisService(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void saveRefreshToken(String refreshToken, String username) {
        redisTemplate.opsForValue().set("refresh_token:" + refreshToken, username, 7, TimeUnit.DAYS);
    }

    public boolean isRefreshTokenValid(String refreshToken) {
        return redisTemplate.hasKey("refresh_token:" + refreshToken);
    }

    public void deleteRefreshToken(String refreshToken) {
        redisTemplate.delete("refresh_token:" + refreshToken);
    }

    public void addToBlacklist(String accessToken, long expirationMs) {
        redisTemplate.opsForValue().set("blacklist:" + accessToken, "invalid", expirationMs, TimeUnit.MILLISECONDS);
    }

    public boolean isInBlacklist(String accessToken) {
        return Boolean.TRUE.equals(redisTemplate.hasKey("blacklist:" + accessToken));
    }
}

Filter过滤器

// JWT过滤器
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class JwtFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;
    private final RedisService redisService;

    public JwtFilter(JwtUtil jwtUtil, RedisService redisService) {
        this.jwtUtil = jwtUtil;
        this.redisService = redisService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) 
        throws ServletException, IOException {
        
        String token = resolveToken(request);
        if (token == null) {
            filterChain.doFilter(request, response);
            return;
        }

        if (redisService.isInBlacklist(token)) {
            sendError(response, "Token invalid");
            return;
        }

        if (jwtUtil.validateToken(token)) {
            String username = jwtUtil.getUsernameFromToken(token);
            UsernamePasswordAuthenticationToken authentication = 
                new UsernamePasswordAuthenticationToken(username, null, null);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            filterChain.doFilter(request, response);
        } else {
            sendError(response, "Token expired or invalid");
        }
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }

    private void sendError(HttpServletResponse response, String message) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write(message);
        response.getWriter().flush();
    }
}

Controller类

// 控制器类
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
public class AuthController {
    private final JwtUtil jwtUtil;
    private final RedisService redisService;

    public AuthController(JwtUtil jwtUtil, RedisService redisService) {
        this.jwtUtil = jwtUtil;
        this.redisService = redisService;
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request) {
        // 这里应添加用户认证逻辑(如数据库验证)
        String username = request.getUsername();
        
        String accessToken = jwtUtil.generateAccessToken(username);
        String refreshToken = jwtUtil.generateRefreshToken(username);
        
        redisService.saveRefreshToken(refreshToken, username);
        return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken));
    }

    @PostMapping("/refresh")
    public ResponseEntity<?> refreshToken(@RequestBody RefreshRequest request) {
        String refreshToken = request.getRefreshToken();
        if (!redisService.isRefreshTokenValid(refreshToken)) {
            return ResponseEntity.status(401).body("Invalid refresh token");
        }

        String username = jwtUtil.getUsernameFromToken(refreshToken);
        String newAccessToken = jwtUtil.generateAccessToken(username);
        String newRefreshToken = jwtUtil.generateRefreshToken(username);

        // 替换旧refreshToken
        redisService.deleteRefreshToken(refreshToken);
        redisService.saveRefreshToken(newRefreshToken, username);

        // 将旧accessToken加入黑名单(可选)
        // long expiration = jwtUtil.getExpirationFromToken(refreshToken).getTime() - System.currentTimeMillis();
        // redisService.addToBlacklist(refreshToken, expiration);

        return ResponseEntity.ok(new TokenResponse(newAccessToken, newRefreshToken));
    }

    // DTO类
    private static class LoginRequest {
        private String username;
        private String password;
        // getters/setters
    }

    private static class RefreshRequest {
        private String refreshToken;
        // getters/setters
    }

    private static class TokenResponse {
        private final String accessToken;
        private final String refreshToken;
        // constructor/getters
    }
}

问题解析

相比单Token的优势

  • 高安全性:用户请求仅携带过期时间较短的AccessToken,即使令牌泄露,风险时间窗口也较小;用户仅在请求刷新Token时携带RefreshToken
  • 长会话:RefreshToken一般设置较长的过期时间,只要RT不过期用户就无需重复登录

引入Redis的作用

  • 方便状态管理:如果不存在Redis,用户登出后只能等待Token过期才能被动失效,增加Token暴露风险;通过在Redis中引入黑名单blacklist,可以使得Token主动失效
  • 多端会话管理:通过以 userId:deviceId为键存储不同设备的Token,实现同用户多端登录。通过删除对应设备的键并加上黑名单,可以主动剔出对应设备
  • 分布式一致性:若使用本地内存存储 RT,在分布式多节点架构中,各节点无法共享 RT 状态,导致用户在一个节点退出后,其他节点仍认为 RT 有效。Redis作为集中式存储,确保所有服务节点访问同一份 RT 数据,状态一致。

保证Token安全性

  • 存储安全性:AT存于内存或 SessionStorage(页面关闭失效),而RT通过 HttpOnly; Secure; SameSite=Strict Cookie 存储(XSS攻击无效)。
  • 传输安全性:开启HTTPS,防止中间人攻击(篡改、伪造和窃听);AT通过 Authorization: Bearer {token} 请求头传递,避免 URL 参数(防日志泄露),而RT通过 Cookie(标记 HttpOnly; Secure)传输。