點擊上方“程序員蝸牛g”,選擇“設為星標”
跟蝸牛哥一起,每天進步一點點
程序員蝸牛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("查詢用户...") ;
}
}
接下來,調用退出接口
redis中已經有了數據
我們用同樣的token再次訪問/api/query接口
如果這篇文章對您有所幫助,或者有所啓發的話,求一鍵三連:點贊、轉發、在看。
關注公眾號:woniuxgg,在公眾號中回覆:筆記 就可以獲得蝸牛為你精心準備的java實戰語雀筆記,回覆面試、開發手冊、有超讚的粉絲福利!