網路上關於 Spring Security 的文章大多停留在入門層級,要么是配置 UserDetailsService,要么就是簡單的路由控制,且不少還在使用 XML 配置。本文將跳過基礎內容,專注於以 Java Config 方式配置多個 AuthenticationProvider,實現記憶體認證與資料庫認證的混合模式。
Spring Security 本質上是一組 Filter 鏈,核心流程如下:
UsernamePasswordAuthenticationFilter 攔截登入請求AuthenticationProvider 集合當 AuthenticationProvider.authenticate() 返回 null 時,ProviderManager 會嘗試下一個支援該 Authentication 類型的 Provider,這就是多 Provider 鏈式調用的關鍵機制。
首先建立一個僅支援 root/root 帳號的記憶體認證 Provider:
@Component
public class InMemoryAuthenticationProvider implements AuthenticationProvider {
private final String adminName = "root";
private final String adminPassword = "root";
private final List<GrantedAuthority> authorities = Arrays.asList(
new SimpleGrantedAuthority("CAN_SEARCH"),
new SimpleGrantedAuthority("CAN_EXPORT"),
new SimpleGrantedAuthority("CAN_IMPORT"),
new SimpleGrantedAuthority("CAN_BORROW"),
new SimpleGrantedAuthority("CAN_RETURN"),
new SimpleGrantedAuthority("CAN_REPAIR"),
new SimpleGrantedAuthority("CAN_DISCARD"),
new SimpleGrantedAuthority("CAN_EMPOWERMENT"),
new SimpleGrantedAuthority("CAN_BREED")
);
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (isMatch(authentication)) {
User user = new User(authentication.getName(),
authentication.getCredentials().toString(), authorities);
return new UsernamePasswordAuthenticationToken(user, authentication.getCredentials(), authorities);
}
return null; // 交給下一個 Provider
}
@Override
public boolean supports(Class<?> authentication) {
return true; // 簡化處理,實際建議判斷具體類型
}
private boolean isMatch(Authentication authentication) {
return adminName.equals(authentication.getName())
&& adminPassword.equals(authentication.getCredentials());
}
}
關鍵點:supports() 返回 true 表示支援所有類型,實際專案中建議精確匹配 UsernamePasswordAuthenticationToken.class。
資料庫認證直接複用 Spring Security 內建的 DaoAuthenticationProvider:
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider(UserDetailsService userServiceDetails) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setPasswordEncoder(new BCryptPasswordEncoder());
provider.setUserDetailsService(userServiceDetails);
return provider;
}
在 WebSecurityConfigurerAdapter 中覆寫 authenticationManager():
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userServiceDetails;
@Autowired
private InMemoryAuthenticationProvider inMemoryAuthenticationProvider;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.rememberMe().alwaysRemember(true).tokenValiditySeconds(86400).and()
.authorizeRequests()
.antMatchers("/", "/*swagger*/**", "/v2/api-docs").permitAll()
.anyRequest().authenticated().and()
.formLogin()
.loginPage("/")
.loginProcessingUrl("/login")
.successHandler(new AjaxLoginSuccessHandler())
.failureHandler(new AjaxLoginFailureHandler()).and()
.logout().logoutUrl("/logout").logoutSuccessUrl("/");
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/public/**", "/webjars/**", "/v2/**", "/swagger**");
}
@Override
protected AuthenticationManager authenticationManager() throws Exception {
ProviderManager manager = new ProviderManager(
Arrays.asList(inMemoryAuthenticationProvider, daoAuthenticationProvider())
);
// 關鍵:不清除憑證,否則 TokenBasedRememberMeServices 會因找不到 Credentials 拋出異常
manager.setEraseCredentialsAfterAuthentication(false);
return manager;
}
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setPasswordEncoder(new BCryptPasswordEncoder());
provider.setUserDetailsService(userServiceDetails);
return provider;
}
@Override
protected UserDetailsService userDetailsService() {
return userServiceDetails; // RememberMeServices 需要
}
}
Arrays.asList(inMemoryAuthenticationProvider, daoAuthenticationProvider()) 中,記憶體 Provider 在前,意味著 root 帳號會優先被攔截認證,失敗才轉交資料庫 Provider。
setEraseCredentialsAfterAuthentication(false)預設認證成功後會清除密碼。但 TokenBasedRememberMeServices 在生成記住我 Token 時需要讀取憑證,若已清除會觸發 UserDetailsService.loadUserByUsername 導致 UsernameNotFoundException。
多 AuthenticationProvider 配置的核心在於:
ProviderManager 組裝 Provider 列表,順序即優先級eraseCredentialsAfterAuthentication 等行為這種方式比在單一 UserDetailsService 中硬編碼多種認證邏輯更符合開閉原則,也更易於擴展(如加入 LDAP、OAuth2 等 Provider)。