跳至主要内容

安全性模組使用指南

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,登入流程無需感知鎖定:帳密登入照常經 AuthenticationManagerDaoAuthenticationProvider,鎖定檢查在 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(...)LockoutExceptionApplicationException 家族,帶失敗 次數與剩餘分鐘)。它先於 Spring 的帳號狀態旗標檢查,一路傳播到框架的 GlobalRestExceptionHandler 統一映為 RFC 7807 回應(參考實作的 AuthControllerLockoutException 明確 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,由 ResourceServerFactoryapp.security.auth.mode 供應。消費端的 SecurityConfigResourceServerSecurity 組裝 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-serverfeature/auth/SecurityConfig

不要改回 requestMatchers("/api/v1/auth/**").permitAll():wildcard 會讓未來新增的 auth 端點自動匿名可及。exact method + path 使新端點預設落入 authenticated()

多租戶整合

bearer 路徑的租戶由 JWT 的 tenantId claim 解析;非 JWT 路徑(Basic Auth、API key)的租戶則來自 主體——只要主體實作 TenantAwareUserDetailsCompositeTenantIdResolver.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 參考: SecurityJavadoc