安全性模組使用指南
Package:
io.leandev.appfuse.security.*狀態: 穩定
簡介
安全性模組提供三組核心能力:
- Token 黑名單:管理已撤銷的 JWT token,確保 logout 後 token 無法再使用
- Login Lockout:登入鎖定功能,防止暴力破解攻擊
- Resource Server 組裝:統一 blacklist、Bearer、API key、Basic Auth 的 filter 順序, 但不持有任何 URL 或授權規則
核心特色
| 特色 | 說明 |
|---|---|
| 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. 以 capability 組裝固定機制
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
TokenBlacklistStore tokenBlacklistStore,
ObjectMapper objectMapper,
JwtTokenProvider jwtTokenProvider,
JwtDecoder jwtDecoder,
Converter<Jwt, AbstractAuthenticationToken> jwtConverter,
AuthenticationEntryPoint bearerEntryPoint,
AccessDeniedHandler bearerAccessDeniedHandler) throws Exception {
ResourceServerSecurity.builder(
http, tokenBlacklistStore, objectMapper, jwtTokenProvider,
jwtDecoder, jwtConverter, bearerEntryPoint, bearerAccessDeniedHandler)
.configure();
// URL、HTTP method 與公開政策由 reference implementation 精確決定。
http.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST,
"/api/v1/auth/login",
"/api/v1/auth/refresh",
"/api/v1/auth/logout")
.permitAll()
.anyRequest().authenticated());
return http.build();
}
/api/v1/... 是 app-tenant-server 的參考位址,不是 capability 或 Feature 固定的路徑。完整分層與
公開端點原則見 Feature 設計與使用。
Logout 整合
@PostMapping("/logout")
public ResponseEntity<?> logout(
@RequestHeader(value = "Authorization", required = false) 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
登入失敗鎖定完全走 Spring Security 既有機制,不包裝 AuthenticationProvider、也不需要
controller 手動介入(io.leandev.appfuse.security.lockout.LoginLockout)。兩個掛接點:
| 職責 | 機制 |
|---|---|
| 計數 | @EventListener 監聽 AuthenticationFailureBadCredentialsEvent / AuthenticationSuccessEvent(框架的認證事件,與哪個 provider 認證無關) |
| 把關 | 實作 UserDetailsChecker,掛在 DaoAuthenticationProvider.setPreAuthenticationChecks(...)——Spring 為帳號狀態檢查預留的插槽;鎖定中先於標準旗標檢查拋 LockoutException |
因為靠事件與 checker,登入流程無需感知鎖定:帳密登入照常經 AuthenticationManager →
DaoAuthenticationProvider,鎖定檢查在 provider 內自動觸發。controller 不查、不記、不清。
接線
// 1. 失敗記錄快取(TTI 過期由快取承擔;納入統一快取監控)
@Bean
public Cache<String, AttemptRecord> attemptCache(CacheManager cacheManager) {
return CacheBuilder
.newCache(cacheManager, "loginAttempts", String.class, AttemptRecord.class)
.heap(10000)
.tti(30) // 30 分鐘無活動即過期
.managed(true)
.build();
}
// 2. 鎖定器:連續 5 次失敗起鎖定,時長以 1 分鐘為單位遞增
@Bean
public LoginLockout loginLockout(Cache<String, AttemptRecord> attemptCache) {
return new LoginLockout(attemptCache, 5, Duration.ofMinutes(1));
}
// 3. capability 建立 DAO manager,固定補上容易遺漏的 event publisher;
// 應用仍決定 UserDetailsService、PasswordEncoder 與 pre-auth checks。
@Bean
public AuthenticationManager authenticationManager(
UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder,
LoginLockout loginLockout,
ApplicationEventPublisher publisher) {
return DaoAuthenticationManagers
.builder(userDetailsService, passwordEncoder, publisher)
.preAuthenticationChecks(loginLockout)
.build();
}
DaoAuthenticationManagers 把「手建 ProviderManager 必須發布事件」收進 capability,避免每個
reference implementation 重複一段容易靜默漏掉鎖定的樣板。是否套用 LoginLockout 仍是應用政策;
例如參考實作的 M2M manager 刻意不套用互動登入鎖定。
鎖定時長曲線
預設為線性遞增——達閾值當次 = 1 × step,之後每多失敗一次多 1 × step(閾值 5、step 1
分鐘:第 5 次鎖 1 分、第 6 次 2 分、第 7 次 3 分…)。要換成固定時長或指數退避,覆寫
lockoutDuration(int failureCount) 即可,不需要另一個政策類:
// 固定 15 分鐘
LoginLockout fixed = new LoginLockout(cache, 5, Duration.ofMinutes(15)) {
@Override protected Duration lockoutDuration(int failureCount) {
return Duration.ofMinutes(15);
}
};
// 指數退避:base × 2^(count - threshold)
LoginLockout exponential = new LoginLockout(cache, 3, Duration.ofMinutes(2)) {
@Override protected Duration lockoutDuration(int failureCount) {
return Duration.ofMinutes(2).multipliedBy(1L << (failureCount - 3));
}
};
AttemptRecord(不可變 record:failureCount + lockedUntil)由 LoginLockout 存入快取;
isLocked(now) / remainingMinutes(now) 供鎖定判定與訊息組裝。
鎖定被觸發時的回應
鎖定中登入,LoginLockout.check(...) 拋 LockoutException(ApplicationException 家族,帶失敗
次數與剩餘分鐘)。它先於 Spring 的帳號狀態旗標檢查,一路傳播到框架的 GlobalRestExceptionHandler
統一映為 RFC 7807 回應(參考實作的 AuthController 對 LockoutException 明確 rethrow 給全域處理,
不自行吞掉)。訊息內含剩餘分鐘,無需 controller 自行計算。
管理操作:解鎖
LoginLockout.clear(username) 清除失敗記錄並解除鎖定,供帳號管理面的「解鎖」端點呼叫:
@PostMapping("/api/v1/accounts/{username}/unlock")
@PreAuthorize("hasAuthority('account:manage')")
public ResponseEntity<Void> unlock(@PathVariable String username) {
loginLockout.clear(username);
return ResponseEntity.noContent().build();
}
儲存與分散式部署
參考實作以 AppFuse Cache(attemptCache)承擔記錄,TTI 自動清理不活躍帳號、納入統一快取監控。
多節點部署要共享鎖定狀態時,把該快取換成分散式後端(如 Redis 支援的 CacheManager)即可——
LoginLockout 只依賴 Cache<String, AttemptRecord> 介面,不感知底層。
注意:鍵為 username。要同時對 IP 限速,另加一層(如閘道或獨立的 rate limiter),不在本機制內。
認證 Filter
認證的核心是標準 oauth2ResourceServer().jwt()(ADR-009 雙模式資源伺服器),兩種 token 來源
(自簽 STANDALONE / 企業 IdP FEDERATED)共用同一驗證核心與 @PreAuthorize 授權,差異只在
decoder / converter,由 ResourceServerFactory 依 app.security.auth.mode 供應。消費端的
SecurityConfig 以 ResourceServerSecurity 組裝 filter chain、供應政策輸入,
不自寫 JWT filter。Capability 完全不持有 URL、HTTP method 或 authorization rule。
Filter Chain(參考實作現況)
Request
→ TokenBlacklistFilter ← bearer 驗證前先查黑名單(撤銷的 token 即使未到期也擋下)
→ [ApiKeyAuthenticationFilter] ← 選用(app.security.api-key.enabled;ADR-025)
→ [BasicAuthenticationFilter] ← 選用(app.security.basic-auth.enabled)
→ oauth2ResourceServer().jwt() ← bearer 驗證核心(ADR-009;免費取得 RFC 6750 挑戰標頭)
→ Controller(@PreAuthorize 授權)
TokenBlacklistFilter(恆在):以 session id 查黑名單,命中直接回 401;登出即靠它讓未到期 的 token 失效。STANDALONE 撤銷自簽 token;FEDERATED 的 IdP token 無本地 session id、解析失敗即 略過(撤銷由 IdP 承擔)。ApiKeyAuthenticationFilter(選用):靜態 key 認證,供做不了 token exchange 的外部系統 (ADR-025 決策五);預設不註冊。無X-Api-Key標頭的請求完全不受影響。BasicAuthenticationFilter(選用):以帳密走authenticationManager(因而自動含上面的 登入鎖定)認證,供 curl / Bruno 等非互動式工具;預設不註冊。- 三個選用 filter 都是「無對應標頭即 pass-through」,且都置於 bearer 驗證之前、
TokenBlacklistFilter之後。
⚠️ 舊版本文檔曾示範一個
DelegatingJwtAuthenticationFilter(自寫 JWT filter +LocalJwtAuthenticationProvider/OAuthJwtAuthenticationProvider)。那個架構已隨 ADR-009 移除——bearer 驗證改用 Spring 內建的 標準資源伺服器,不再有自寫的 JWT filter。
組裝範例(節錄)
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, /* … */) throws Exception {
ResourceServerSecurity
.builder(
http, tokenBlacklistStore, objectMapper, jwtTokenProvider,
jwtDecoder, jwtAuthenticationConverter, bearerEntryPoint, bearerAccessDeniedHandler)
.apiKey(apiKeyEnabled, apiKeyLookup, apiKeyHeader)
.basicAuth(basicAuthEnabled, authenticationManager, basicEntryPoint)
.configure();
http.authorizeHttpRequests(auth -> auth
// app-tenant-server 的參考白名單;下游依自己的 controller mapping 決定。
.requestMatchers(HttpMethod.POST,
"/api/v1/auth/login",
"/api/v1/auth/refresh",
"/api/v1/auth/logout",
"/api/v1/auth/token",
"/api/v1/auth/exchange")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/auth/link/consume")
.permitAll()
.anyRequest().authenticated());
return http.build();
}
完整範例見參考實作 app-tenant-server 的 feature/auth/SecurityConfig。
不要改回 requestMatchers("/api/v1/auth/**").permitAll():wildcard 會讓未來新增的 auth
端點自動匿名可及。exact method + path 使新端點預設落入 authenticated()。
多租戶整合
bearer 路徑的租戶由 JWT 的 tenantId claim 解析;非 JWT 路徑(Basic Auth、API key)的租戶則來自
主體——只要主體實作 TenantAwareUserDetails,CompositeTenantIdResolver.withDefaults() 內建的
UserDetailsTenantIdResolver 就會自動把它填進 TenantContext,零額外接線。
框架提供 DefaultAuthPrincipal(實作 AuthPrincipal + TenantAwareUserDetails),由消費端的
UserDetailsService 把自家帳號攤平產生(ADR-023)——帳號 entity 不直接實作 UserDetails,
一律經 adapter 轉接:
return DefaultAuthPrincipal.builder(account.getUsername())
.password(account.getPassword())
.authorities(authorities)
.tenantId(account.getTenantId()) // 無租戶(ownership 取向)部署省略此行
.build();
需要在主體上多帶欄位時繼承 DefaultAuthPrincipal 即可;租戶解析與登入引擎皆不受影響。
API 參考
詳細的類別設計和方法簽名,請參閱 API 參考: Security 或 Javadoc。