跳至主要内容

安全性模組使用指南

Package: io.leandev.appfuse.security.* 狀態: 穩定


簡介

安全性模組提供兩大核心功能:

  1. Token 黑名單:管理已撤銷的 JWT token,確保 logout 後 token 無法再使用
  2. Login Lockout:登入鎖定功能,防止暴力破解攻擊

核心特色

特色說明
Token 黑名單Logout 後立即使 token 失效
登入失敗追蹤記錄使用者登入失敗次數
帳號鎖定超過閾值後暫時鎖定帳號
多種策略固定、遞增、指數等鎖定策略
Spring Security 整合無縫整合認證流程
AppFuse Cache 整合統一快取機制,支援自動過期
可替換儲存記憶體/AppFuse Cache/Redis

Token 黑名單

問題背景

JWT 是無狀態的,token 一旦簽發,在過期前都是有效的。這意味著:

  • 使用者 logout 後,token 仍可被使用
  • 若 token 被盜取,無法立即撤銷

解決方案

使用 Token 黑名單機制,在 JWT 驗證前先檢查 token 是否已被撤銷。

快速開始

import io.leandev.appfuse.security.blacklist.store.*;
import io.leandev.appfuse.security.blacklist.spring.*;

// 1. 建立快取(建議 TTL 與 access token 過期時間一致)
@Bean
public Cache<String, Boolean> tokenBlacklistCache(CacheManager cacheManager) {
return CacheBuilder
.newCache(cacheManager, "tokenBlacklist", String.class, Boolean.class)
.heap(1000)
.ttl(15) // 15 分鐘(與 access token 相同)
.build();
}

// 2. 建立 Store
@Bean
public TokenBlacklistStore tokenBlacklistStore(Cache<String, Boolean> cache) {
return new CacheTokenBlacklistStore(cache);
}

// 3. 在 SecurityFilterChain 中加入 Filter
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
TokenBlacklistStore tokenBlacklistStore,
ObjectMapper objectMapper) throws Exception {

TokenBlacklistFilter blacklistFilter =
new TokenBlacklistFilter(tokenBlacklistStore, objectMapper);

http
.addFilterBefore(blacklistFilter, UsernamePasswordAuthenticationFilter.class)
// ... 其他配置
}

Logout 整合

@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestHeader("Authorization") String authHeader) {
// 提取 token
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
tokenBlacklistStore.add(token); // 加入黑名單
}

SecurityContextHolder.clearContext();
return ResponseEntity.ok().build();
}

設計優點

優點說明
TTL 自動過期Token 過期後黑名單記錄自動清除,節省記憶體
統一快取機制與 Login Lockout 使用相同的 AppFuse Cache
可擴充 Redis未來可支援分散式部署

Login Lockout

快速開始

import io.leandev.appfuse.security.lockout.*;
import java.time.Duration;

// 1. 建立登入嘗試追蹤器
LoginAttemptTracker tracker = new DefaultLoginAttemptTracker(
new InMemoryAttemptStore(),
new IncrementalLockoutPolicy(5, Duration.ofMinutes(1)) // 5 次失敗後鎖定
);

// 2. 在登入流程中使用
public void handleLogin(String username, String password) {
// 檢查是否已被鎖定
if (tracker.isLocked(username)) {
Duration remaining = tracker.getRemainingLockoutTime(username)
.orElse(Duration.ZERO);
throw new LockoutException(
"帳號已被鎖定,請在 " + remaining.toMinutes() + " 分鐘後再試"
);
}

try {
// 驗證密碼...
authenticate(username, password);

// 登入成功,清除失敗記錄
tracker.clearAttempts(username);

} catch (AuthenticationException e) {
// 登入失敗,記錄失敗次數
tracker.recordFailure(username);
throw e;
}
}

Spring Security 整合

@Configuration
public class SecurityConfig {

@Bean
public LoginAttemptTracker loginAttemptTracker() {
return new DefaultLoginAttemptTracker(
new InMemoryAttemptStore(),
new IncrementalLockoutPolicy(5, Duration.ofMinutes(1))
);
}

@Bean
public LockoutAwareDaoAuthenticationProvider lockoutAwareProvider(
DaoAuthenticationProvider daoProvider,
LoginAttemptTracker tracker) {
return new LockoutAwareDaoAuthenticationProvider(daoProvider, tracker);
}
}

鎖定策略

策略比較

策略公式適用場景
IncrementalLockoutPolicyduration = count × base平衡,推薦使用
FixedLockoutPolicyduration = fixed簡單、可預測
ExponentialLockoutPolicyduration = base^n嚴格、最大保護

IncrementalLockoutPolicy(遞增策略)

鎖定時間隨失敗次數線性增加:

// 5 次失敗後開始鎖定,每次增加 1 分鐘
LockoutPolicy policy = new IncrementalLockoutPolicy(5, Duration.ofMinutes(1));

// 第 5 次失敗: 鎖定 1 分鐘
// 第 6 次失敗: 鎖定 2 分鐘
// 第 7 次失敗: 鎖定 3 分鐘

FixedLockoutPolicy(固定策略)

每次鎖定相同時間:

// 3 次失敗後鎖定 15 分鐘
LockoutPolicy policy = new FixedLockoutPolicy(3, Duration.ofMinutes(15));

// 第 3 次失敗: 鎖定 15 分鐘
// 第 4 次失敗: 鎖定 15 分鐘
// 第 5 次失敗: 鎖定 15 分鐘

ExponentialLockoutPolicy(指數策略)

鎖定時間指數增長:

// 3 次失敗後開始鎖定,基數 2 分鐘
LockoutPolicy policy = new ExponentialLockoutPolicy(3, Duration.ofMinutes(2));

// 第 3 次失敗: 鎖定 2 分鐘 (2^1)
// 第 4 次失敗: 鎖定 4 分鐘 (2^2)
// 第 5 次失敗: 鎖定 8 分鐘 (2^3)
// 第 6 次失敗: 鎖定 16 分鐘 (2^4)

儲存機制

InMemoryAttemptStore(記憶體)

適用於開發環境或單節點部署:

AttemptStore store = new InMemoryAttemptStore();

注意:無自動過期機制,記憶體會隨使用者數量增長。

CacheAttemptStore(推薦)

使用 AppFuse Cache,支援自動過期:

// 1. 建立快取
@Bean
public Cache<String, AttemptRecord> attemptCache(CacheManager cacheManager) {
return CacheBuilder
.newCache(cacheManager, "loginAttempts", String.class, AttemptRecord.class)
.heap(1000)
.tti(30) // 30 分鐘無活動則過期
.build();
}

// 2. 建立 Store 和 Tracker
@Bean
public LoginAttemptTracker loginAttemptTracker(Cache<String, AttemptRecord> attemptCache) {
AttemptStore store = new CacheAttemptStore(attemptCache);
LockoutPolicy policy = new IncrementalLockoutPolicy(5, Duration.ofMinutes(1));
return new DefaultLoginAttemptTracker(store, policy);
}

優點

  • TTI 自動清理不活躍記錄
  • 與 Token 黑名單使用相同技術棧
  • 未來可擴充 Redis 支援分散式

自訂儲存(Redis/資料庫)

實作 AttemptStore 介面:

public class RedisAttemptStore implements AttemptStore {
private final RedisTemplate<String, AttemptRecord> redisTemplate;

@Override
public int getFailureCount(String principal) {
AttemptRecord record = redisTemplate.opsForValue().get(key(principal));
return record != null ? record.getFailureCount() : 0;
}

@Override
public int incrementFailureCount(String principal) {
AttemptRecord record = redisTemplate.opsForValue().get(key(principal));
if (record == null) {
record = new AttemptRecord();
}
int newCount = record.incrementFailureCount();
redisTemplate.opsForValue().set(key(principal), record, Duration.ofHours(24));
return newCount;
}

// ... 其他方法

private String key(String principal) {
return "login:attempt:" + principal;
}
}

架構概覽

API Layer        → LoginAttemptTracker, LockoutPolicy, AttemptStore

Core Layer → DefaultLoginAttemptTracker

Store Layer → InMemoryAttemptStore, (Redis/Database)

Spring Layer → LockoutAwareDaoAuthenticationProvider

常見場景

場景 1: REST API 登入

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
String username = request.getUsername();

// 檢查鎖定狀態
if (tracker.isLocked(username)) {
Duration remaining = tracker.getRemainingLockoutTime(username)
.orElse(Duration.ZERO);
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(new ErrorResponse("ACCOUNT_LOCKED",
"請在 " + remaining.toMinutes() + " 分鐘後再試"));
}

try {
String token = authService.authenticate(username, request.getPassword());
tracker.clearAttempts(username);
return ResponseEntity.ok(new LoginResponse(token));

} catch (BadCredentialsException e) {
tracker.recordFailure(username);

int remaining = tracker.getRemainingAttempts(username);
if (remaining > 0) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("INVALID_CREDENTIALS",
"密碼錯誤,還剩 " + remaining + " 次機會"));
} else {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(new ErrorResponse("ACCOUNT_LOCKED", "帳號已被鎖定"));
}
}
}

場景 2: 管理員解鎖

@PostMapping("/admin/unlock/{username}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> unlockAccount(@PathVariable String username) {
tracker.clearAttempts(username);
return ResponseEntity.ok("帳號 " + username + " 已解鎖");
}

場景 3: 查詢帳號狀態

@GetMapping("/account/{username}/status")
public ResponseEntity<?> getAccountStatus(@PathVariable String username) {
boolean locked = tracker.isLocked(username);
int failedAttempts = tracker.getFailedAttempts(username);
Optional<Duration> remaining = tracker.getRemainingLockoutTime(username);

return ResponseEntity.ok(Map.of(
"locked", locked,
"failedAttempts", failedAttempts,
"remainingLockoutMinutes", remaining.map(Duration::toMinutes).orElse(0L)
));
}

最佳實踐

✅ 推薦

  1. 使用遞增策略:平衡安全性和使用者體驗
  2. 分散式部署使用 Redis:確保多節點一致
  3. 記錄安全日誌:追蹤異常登入行為
  4. 通知使用者:告知剩餘嘗試次數和鎖定時間

❌ 避免

  1. 過於嚴格的策略:可能影響正常使用者
  2. 永久鎖定:應設定最大鎖定時間
  3. 僅依賴用戶名:考慮同時追蹤 IP

常見問題

Q: 如何選擇鎖定策略?

A:

  • 一般應用:IncrementalLockoutPolicy(推薦)
  • 高安全性應用:ExponentialLockoutPolicy
  • 簡單場景:FixedLockoutPolicy

Q: 鎖定時間多長合適?

A: 建議初始 1-5 分鐘,最大 30-60 分鐘。過長會影響使用者體驗,過短則保護不足。

Q: 分散式部署如何處理?

A: 使用 Redis 或資料庫作為儲存機制,確保多節點共享鎖定狀態。


認證 Filter

安全性模組提供兩種認證 Filter,可根據需求搭配使用。

Filter 概覽

Filter處理的 Header適用場景
DelegatingJwtAuthenticationFilterAuthorization: Bearer <token>JWT 認證(本地/OAuth2)
BasicAuthenticationFilterAuthorization: Basic <credentials>HTTP Basic Auth

Filter Chain 順序

Request → DelegatingJwtAuthenticationFilter → BasicAuthenticationFilter → Controller

設計原則

  • JWT Filter 先執行,處理 Bearer token
  • Basic Auth Filter 檢查 SecurityContext,若已認證則跳過
  • 兩者可同時啟用,互不衝突

DelegatingJwtAuthenticationFilter

處理 JWT Bearer token 認證。

// 在 SecurityConfig 中配置
DelegatingJwtAuthenticationFilter jwtFilter =
new DelegatingJwtAuthenticationFilter(authenticationManager, authenticationEntryPoint);

http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

支援的認證方式

  • 本地 JWT(透過 LocalJwtAuthenticationProvider
  • OAuth2 JWT(透過 OAuthJwtAuthenticationProvider

BasicAuthenticationFilter

處理 HTTP Basic Authentication。

// 在 SecurityConfig 中配置(在 JWT Filter 之後)
if (basicAuthEnabled) {
http.addFilterAfter(
new BasicAuthenticationFilter(authenticationManager, authenticationEntryPoint),
DelegatingJwtAuthenticationFilter.class
);
}

特點

  • 與 JWT Filter 共存(先檢查 SecurityContext)
  • 自動整合 Login Lockout 機制
  • 支援多租戶(透過 TenantAwareUserDetails

完整配置範例

@Configuration
public class SecurityConfig {

@Value("${app.security.basic-auth.enabled:false}")
private boolean basicAuthEnabled;

@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
AuthenticationManager authenticationManager,
JsonAuthenticationEntryPoint entryPoint) throws Exception {

// 建立 JWT Filter
DelegatingJwtAuthenticationFilter jwtFilter =
new DelegatingJwtAuthenticationFilter(authenticationManager, entryPoint);

http
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/login").permitAll()
.anyRequest().authenticated()
);

// 選擇性啟用 Basic Auth
if (basicAuthEnabled) {
http.addFilterAfter(
new BasicAuthenticationFilter(authenticationManager, entryPoint),
DelegatingJwtAuthenticationFilter.class
);
}

return http.build();
}
}

多租戶整合

當使用 Basic Auth 時,租戶 ID 從 TenantAwareUserDetails 取得:

// UserDetails 實作 TenantAwareUserDetails 介面
public class AccountUserDetails extends User implements TenantAwareUserDetails {
private final String tenantId;

@Override
public String getTenantId() {
return tenantId;
}
}

TenantContext 會自動透過 UserDetailsTenantIdResolver 解析租戶 ID。


API 參考

詳細的類別設計和方法簽名,請參閱 API 參考: SecurityJavadoc