安全性模組使用指南
Package:
io.leandev.appfuse.security.*狀態: 穩定
簡介
安全性模組提供兩大核心功能:
- Token 黑名單:管理已撤銷的 JWT token,確保 logout 後 token 無法再使用
- 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);
}
}
鎖定策略
策略比較
| 策略 | 公式 | 適用場景 |
|---|---|---|
| IncrementalLockoutPolicy | duration = count × base | 平衡,推薦使用 |
| FixedLockoutPolicy | duration = fixed | 簡單、可預測 |
| ExponentialLockoutPolicy | duration = 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)
));
}
最佳實踐
✅ 推薦
- 使用遞增策略:平衡安全性和使用者體驗
- 分散式部署使用 Redis:確保多節點一致
- 記錄安全日誌:追蹤異常登入行為
- 通知使用者:告知剩餘嘗試次數和鎖定時間
❌ 避免
- 過於嚴格的策略:可能影響正常使用者
- 永久鎖定:應設定最大鎖定時間
- 僅依賴用戶名:考慮同時追蹤 IP
常見問題
Q: 如何選擇鎖定策略?
A:
- 一般應用:
IncrementalLockoutPolicy(推薦) - 高安全性應用:
ExponentialLockoutPolicy - 簡單場景:
FixedLockoutPolicy
Q: 鎖定時間多長合適?
A: 建議初始 1-5 分鐘,最大 30-60 分鐘。過長會影響使用者體驗,過短則保護不足。
Q: 分散式部署如何處理?
A: 使用 Redis 或資料庫作為儲存機制,確保多節點共享鎖定狀態。
認證 Filter
安全性模組提供兩種認證 Filter,可根據需求搭配使用。
Filter 概覽
| Filter | 處理的 Header | 適用場景 |
|---|---|---|
DelegatingJwtAuthenticationFilter | Authorization: Bearer <token> | JWT 認證(本地/OAuth2) |
BasicAuthenticationFilter | Authorization: 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 參考: Security 或 Javadoc。