點擊上方“程序員蝸牛g”,選擇“設為星標”

跟蝸牛哥一起,每天進步一點點

5分鐘搞定Token撤銷!_ide

程序員蝸牛g

大廠程序員一枚 跟蝸牛一起 每天進步一點點

33篇原創內容


公眾號



JWT黑名單工作原理

在Spring Boot應用中實現JWT黑名單管理,本質上是構建一套主動失效控制系統:即使令牌尚未過期,也能通過技術手段強制使其失效。

這一機制雖然概念簡單,但需要三個關鍵組件協同工作:

  • Token唯一標識符(Token Fingerprint)
    為每個JWT生成不可篡改的唯一ID,作為後續追蹤的"數字指紋"。這裏我們可以考慮使用JWT標準jti(JWT ID)字段
  • 存儲失效Token組件
    可以採用基於內存、數據庫、Redis其一方式來存儲Token(視為無效的Token)。
  • 請求攔截器(攔截校驗Token)
    通過Filter實現攔截校驗,實現"失效令牌零信任"。

接下來,我們將詳細的完成JWT黑名單功能。

2.實戰案例

環境準備

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
  <groupId>com.github.ben-manes.caffeine</groupId>
  <artifactId>caffeine</artifactId>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt</artifactId>
  <version>0.12.6</version>
</dependency>

2.1 準備工具類

JWT操作工具類

@Component
public class JwtUtils {


  private static final String SECRET_KEY = "aaaabbbbccccdddd1111222233334444" ;
  private static final SecretKeySpec KEY = new SecretKeySpec(SECRET_KEY.getBytes(), "HmacSHA256") ;
  public String generateToken(Map<String, Object> claims) {
    return Jwts.builder().claims(claims)
        .id(UUID.randomUUID().toString().replace("-",""))
        .subject("pack-xg-jwt")
        .issuedAt(new Date())
        .expiration(new Date(System.currentTimeMillis() + 60 * 60 * 1000))
        .signWith(KEY, Jwts.SIG.HS256)
        .compact();
  }
  public Claims parseToken(String token) {
    return Jwts.parser()
        .verifyWith(KEY)
        .build()
        .parseSignedClaims(token)
        .getPayload() ;
  }


  public String getId(String token) {
    try {
      Claims claims = parseToken(token) ;
      return claims.getId() ;
    } catch (Exception e) {
      return null ;
    }
  }
}

響應輸出客户端工具類

@Component
public class WebUtils {
  private static final ObjectMapper objectMapper = new ObjectMapper() ;
  public static void out(HttpServletResponse response, String error) throws IOException, JsonProcessingException {
    response.setContentType("application/json;charset=utf-8") ;
    response.getWriter().print(objectMapper.writeValueAsString(Map.of("code", -1, "error", error))) ;
  }
}

2.2 JWT黑名單存儲

我們這裏設計2種實現方式,分別:caffeine和redis。運行時將根據配置文件決定啓用哪種存儲。

public interface BlacklistStore {
  /**添加jwt唯一標識,以及設置過期時間*/
  public void add(String jti, Instant expiresAt) ;
  /**判斷是否存在*/
  public boolean contains(String jti) ;
}

Caffeine實現

public class CaffeineBlacklistStore implements BlacklistStore {
  private final Cache<String, Instant> cache ;
  public CaffeineBlacklistStore() {
    this.cache = Caffeine.newBuilder()
        .expireAfter(new Expiry<String, Instant>() {
          @Override
          public long expireAfterCreate(String key, Instant value, long currentTime) {
            long remainingNanos = Duration.between(Instant.now(), value).toNanos();
            return Math.max(0, remainingNanos);
          }
          @Override
          public long expireAfterUpdate(String key, Instant value, long currentTime, long currentDuration) {
            // 更新時保持原有過期時間
            return currentDuration ;
          }
          @Override
          public long expireAfterRead(String key, Instant value, long currentTime, long currentDuration) {
            // 讀取時不延長過期時間
            return currentDuration ;
          }
        })
        .removalListener(new RemovalListener<Object, Object>() {
          @Override
          public void onRemoval(Object key, Object value, RemovalCause cause) {
            System.err.printf("緩存key=%s, value=%s被移除,原因:%s%n", key, value, cause) ;
          }
        })
        .build() ;
  }
  @Override
  public void add(String jti, Instant expiresAt) {
    // 將Token唯一標識加入黑名單
    cache.put(jti, expiresAt);
  }
  @Override
  public boolean contains(String jti) {
    Instant jtiPresent = cache.getIfPresent(jti);
    return jtiPresent != null ;
  }
}

我們通過重寫expireAfter實現,動態設置每一個key(jti)的有效期。對於更新和訪問我們不進行任何的處理,保持原來時間。

Redis實現

public class RedisBlacklistStore implements BlacklistStore {
  private final StringRedisTemplate stringRedisTemplate;
  public RedisBlacklistStore(StringRedisTemplate stringRedisTemplate) {
    this.stringRedisTemplate = stringRedisTemplate;
  }
  @Override
  public void add(String jti, Instant expiresAt) {
    if (jti == null || expiresAt == null) {
      return ;
    }
    Duration ttl = Duration.between(Instant.now(), expiresAt);
    if (ttl.isNegative() || ttl.isZero()) {
      return;
    }
    stringRedisTemplate.opsForValue().set(jti, "revoked", ttl);
  }
  @Override
  public boolean contains(String jti) {
    return jti != null && Boolean.TRUE.equals(stringRedisTemplate.hasKey(jti));
  }
}

配置文件中定義配置

pack:
  jwt:
    blacklist:
      type: redis

註冊上面定義的Store

@Configuration
public class BlacklistStoreConfig {
  @Bean
  @ConditionalOnProperty(prefix = "pack.jwt.blacklist", name = "type", havingValue = "caffeine", matchIfMissing = true)
  CaffeineBlacklistStore caffeineBlacklistStore() {
    return new CaffeineBlacklistStore() ;
  }
  @Bean
  @ConditionalOnProperty(prefix = "pack.jwt.blacklist", name = "type", havingValue = "redis")
  RedisBlacklistStore redisBlacklistStore(StringRedisTemplate stringRedisTemplate) {
    return new RedisBlacklistStore(stringRedisTemplate) ;
  }
}

如果配置文件中沒有配置,則默認使用Caffeine實現。

2.3 定義攔截器

通過攔截器,驗證每個請求的token是否有效。

@Component
public class TokenInterceptor implements HandlerInterceptor {
  private final JwtUtils jwtUtils ;
  public TokenInterceptor(JwtUtils jwtUtils) {
    this.jwtUtils = jwtUtils;
  }
  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    String header = request.getHeader("Authorization");
    if (header == null || !header.startsWith("Bearer ")) {
      WebUtils.out(response, "非法訪問");
      return false ;
    }
    String token = header.substring(7);
    try {
      this.jwtUtils.parseToken(token) ;
    } catch (Exception e) {
      WebUtils.out(response, "無效Token") ;
      return false ;
    }
    return true ;
  }
}

註冊攔截器

@Component
public class WebConfig implements WebMvcConfigurer {
  private final TokenInterceptor tokenInterceptor ;
  public WebConfig(TokenInterceptor tokenInterceptor) {
    this.tokenInterceptor = tokenInterceptor;
  }
  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(tokenInterceptor).addPathPatterns("/api/**") ;
  }
}

2.4 驗證JWT是否在黑名單

我們通過Filter實現請求的提前攔截,驗證Token是否已經被加入到黑名單中,如果存在於黑名單中,那麼直接返回錯誤消息。

@Component
public class BlacklistFilter extends OncePerRequestFilter {
  private final BlacklistStore blacklistStore;
  private final JwtUtils jwtUtils ;
  public BlacklistFilter(BlacklistStore blacklistStore, JwtUtils jwtUtils) {
    this.blacklistStore = blacklistStore;
    this.jwtUtils = jwtUtils ;
  }
  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
      throws ServletException, IOException {
    String header = request.getHeader("Authorization");
    if (header != null && header.startsWith("Bearer ")) {
      String token = header.substring(7);
      try {
        Claims claims = this.jwtUtils.parseToken(token) ;
        String jti = claims.getId();
        if (jti == null || blacklistStore.contains(jti)) {
          response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
          WebUtils.out(response, "Token已被回收") ;
          return;
        }
      } catch (JwtException e) {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        WebUtils.out(response, "無效Token") ;
        return;
      }
    }
    chain.doFilter(request, response) ;
  }
}

註冊過濾器

@Configuration
public class FilterConfig {
  @Bean
  FilterRegistrationBean<BlacklistFilter> blacklistFilterRegistration(BlacklistFilter filter) {
    FilterRegistrationBean<BlacklistFilter> reg = new FilterRegistrationBean<>(filter) ;
    reg.addUrlPatterns("/*") ;
    return reg ;
  }
}

該過濾器我們將攔截所有的請求,你可以根據自己的需要進行配置。

2.5 定義登錄/退出接口

登錄接口,生成Token;退出接口則將當前的Token加入到黑名單中。一旦加入黑名單中的Token,都將無法繼續使用即使它沒有過期。

@RestController
@RequestMapping("/login")
public class LoginController {
  private final JwtUtils jwtUtils ;
  public LoginController(JwtUtils jwtUtils) {
    this.jwtUtils = jwtUtils;
  }
  @PostMapping
  public ResponseEntity<?> login(String username) {
    return ResponseEntity.ok(this.jwtUtils.generateToken(Map.of("username", username))) ;
  }
}

退出接口

@RestController
@RequestMapping("/auth")
public class LogoutController {
  private final BlacklistStore blacklistStore;
  private final JwtUtils jwtUtils ;
  public LogoutController(BlacklistStore blacklistStore, JwtUtils jwtUtils) {
    this.blacklistStore = blacklistStore;
    this.jwtUtils = jwtUtils ;
  }
  @PostMapping("/logout")
  public ResponseEntity<String> logout(@RequestHeader("Authorization") String header) {
    if (header != null && header.startsWith("Bearer ")) {
      String token = header.substring(7);
      try {
        Claims claims = this.jwtUtils.parseToken(token) ;
        String jti = claims.getId() ;
        Date exp = claims.getExpiration() ;
        if (jti != null && exp != null) {
          blacklistStore.add(jti, exp.toInstant()) ;
        }
        return ResponseEntity.ok("success") ;
      } catch (JwtException e) {
        return ResponseEntity.ok("error: " + e.getMessage()) ;
      }
    }
    return ResponseEntity.ok("error") ;
  }
}

2.6 驗證

編寫驗證接口

@RestController
@RequestMapping("/api")
public class ApiController {
  @GetMapping("/query")
  public ResponseEntity<?> query() {
    return ResponseEntity.ok("查詢用户...") ;
  }
}

5分鐘搞定Token撤銷!_ide_02

5分鐘搞定Token撤銷!_攔截器_03

接下來,調用退出接口

5分鐘搞定Token撤銷!_ide_04

redis中已經有了數據

5分鐘搞定Token撤銷!_攔截器_05

我們用同樣的token再次訪問/api/query接口

5分鐘搞定Token撤銷!_攔截器_06

如果這篇文章對您有所幫助,或者有所啓發的話,求一鍵三連:點贊、轉發、在看。

關注公眾號:woniuxgg,在公眾號中回覆:筆記  就可以獲得蝸牛為你精心準備的java實戰語雀筆記,回覆面試、開發手冊、有超讚的粉絲福利!