跳至主要内容

安全性模組使用指南

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


簡介​

安全性模組提供三組核心能力:

  1. Token 黑名單:管理已撤銷的 JWT token,確保 logout 後 token 無法再使用
  2. Login Lockout:登入鎖定功能,防止暴力破解攻擊
  3. 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),不在本機制內。


密碼登入請求限流與 Cache 故障(alpha.33 起)​

本節契約已接受,納入 4.0.0-alpha.33;alpha.32 及更早版本不含新 limiter。 參考 server 以 -PuseLocalFramework=true 驗證,採用時須升級包含變更的 artifact 並合併接線。

PasswordLoginRateLimiter.evaluate(remoteAddr, username) 在密碼驗證前計數;預設 CachePasswordLoginRateLimiter 對同一 IP+trim/lowercase(ROOT) username 五次/滾動 60 秒。 成功、錯密碼及未知帳號都消耗配額,成功不清除此配額;LoginLockout 的失敗次數仍獨立。 被拒請求不延長等待,Retry-After 向上取整、至少 1 秒。Clock 可注入驗證邊界。

參考接線由 auth 擁有 passwordLoginRequests cache(heap 10,000、TTI 60 秒)與 limiter bean, HTTP adapter 以 remoteAddr 呼叫,不直接信任 X-Forwarded-For。429 含 Retry-After、 X-RateLimit-Limit/Remaining/Reset(ISO Instant),ProblemDetail 的 errorCode=rate-limit-exceeded、retryAfter 為秒數。cache key 是 IP+username 的 hash,並非加密。

X-RateLimit-* 是參考應用的自訂契約。Reset 使用帶 Z/offset 的 ISO Instant,表示絕對可用時間; client 不應以 epoch 或相對秒數解讀。重試等待以數值秒數的 Retry-After 為準。

反向代理的部署前提​

Public Server URL 參考矩陣 使用 server.forward-headers-strategy=none。若直接部署在反向代理後,未另外配置可信代理解析, remoteAddr 會是代理 IP;共用該代理的所有 client 會共用每個 username 的五次/分鐘配額。 任意來源以任意密碼送五次請求,就能讓該 username 在剩餘視窗內無法登入。 這種部署不能宣稱已按實際 client IP 隔離配額。

上線前須由部署方設定並驗證容器的可信代理解析:只接受可信 ingress,於 ingress 清除/重寫 外來 forwarded headers,並阻擋繞過 ingress 直連 backend。用兩個實際 client IP 驗證應用看到 不同 remoteAddr、配額彼此獨立;具體代理信任設定由部署環境決定,不能直接信任任意 X-Forwarded-For,也不由框架自動改動預設 forward-header 策略。無法取得可信 client IP 時, 應用須另選適合的 gateway/limiter 策略並明示配額範圍。

同步區塊只保證同一 limiter 物件的併發決策;backend/listener 可增加延遲。cache 淘汰、 停用、故障與重啟可能重置配額;不是全叢集硬上限。跨節點需提供原子配額實作, 不能只把普通 get/put cache 換成 Redis。TTI 僅清理,視窗由接受時間判斷。

LoginLockout 與此 limiter 只隔離 cache.isEnabled/get/put/remove 的 RuntimeException。 停用/故障時略過快取保護,仍驗證密碼、disabled/expired/locked/credentials-expired、 互動式登入資格、政策與 SQL Session。SQL 拋例外不能被這個降級吞掉,Error/啟動接線錯誤亦然。 token blacklist、M2M、OIDC/signed-link 的執行中失敗契約未改。

Spring 管理的 LoginLockout(含既有 subclass)與 limiter 自動接 publisher;非 Spring 建立者 須顯式設定。AuditApplicationEvent 的 CACHE_UNAVAILABLE/CACHE_RECOVERED 以 system 為 principal,資料僅 cacheName/component(login-lockout 或 password-login-rate-limit)。 每用途第一次故障發一次;所有曾失敗操作都真正成功才恢復,單純 flag 或 GET 成功不能清除 已知寫入失敗。管理停用不算故障、不清除先前故障;這是用途的觀察,非整個 backend 的保證。 應用訂閱後快速交給既有非同步通知能力;普通 listener 例外不改登入結果,事件送達不保證。

前節 LoginLockout 的 cache 邊界修正保留原建構器與 lockoutDuration 覆寫接點,但 執行中 cache 例外由原先可能導致 500 改為繼續認證,升級需確認此行為修正。

認證 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。