最近在研究 spting-boot 框架和一些 web 開發的基礎安全性問題,花了點時間了解如何實作預防暴力登入的機制。
主要參考這篇文章 Prevent Brute Force Authentication Attempts with Spring Security 。
該篇文章將驗證 request ip 寫在 UserDetailsService 中,而官網對該 interface 的定義為 "Core interface which loads user-specific data"。相較之下我覺得放在提供驗證方法的 AuthenticationProvider (官網定義為 “Indicates a class can process a specific Authentication implementation” )更合適。下面簡介如實作:
LoginAttemptService 提供一個存取登入失敗次數和對應 ip 列表的服務,利用 guava 的 LoadingCache 存取 block list並設定 timeout,藉此實作 block time out 的機制。
@Service public class LoginAttemptService { @Autowired private HttpServletRequest request; private final int MAX_ATTEMPT = 2; private final int bolckTimeMins = 1; private LoadingCache<String, Integer> attemptsCache; public LoginAttemptService() { attemptsCache = CacheBuilder.newBuilder(). expireAfterWrite(bolckTimeMins, TimeUnit.MINUTES).build(new CacheLoader<String, Integer>() { public Integer load(String key) { return 0; } }); } public void loginSucceeded(String key) { attemptsCache.invalidate(key); } public void loginFailed(String key) { int attempts = 0; try { attempts = attemptsCache.get(key); } catch (ExecutionException e) { attempts = 0; } attempts++; attemptsCache.put(key, attempts); } public boolean isBlocked(String key) { try { return attemptsCache.get(key) >= MAX_ATTEMPT; } catch (ExecutionException e) { return false; } } }
一個監聽登入成功事件的 listener,每當用戶登入成功便透過 LoginAttemptService 將該 ip 從 block list 中清除。
@Component public class AuthenticationSuccessEventListener implements ApplicationListener<AuthenticationSuccessEvent> { @Autowired private LoginAttemptService loginAttemptService; public void onApplicationEvent(AuthenticationSuccessEvent e) { WebAuthenticationDetails auth = (WebAuthenticationDetails) e.getAuthentication().getDetails(); loginAttemptService.loginSucceeded(auth.getRemoteAddress()); } }
一個監聽登入失敗事件的 listener,每當用戶登入失敗就透過 LoginAttemptService 將該 ip 放入 block list 中,並記錄失敗次數。
@Component public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException { response.sendError(403, exception.getMessage()); } }
實作一個 UserDetailsService,透過 Spring Data Repositories 讀取使用者資料。
@Service("MyUserDetailsImpl") public class MyUserDetailsService implements UserDetailsService { @Autowired private UserRepository repo; public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException { User user; try { user = repo.getByUsername(userName); } catch (Exception e) { throw new UsernameNotFoundException("user select fail"); } if(user == null){ throw new UsernameNotFoundException("no user found"); } else { try { List<GrantedAuthority> gas = new ArrayList<GrantedAuthority>(); gas.add(new SimpleGrantedAuthority("ROLE_USER")); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), true, true, true, true, gas); } catch (Exception e) { throw new UsernameNotFoundException("user role select fail"); } } } }
實作一個 AuthenticationProvider ,在驗證帳號密碼之前,會先透過 LoginAttemptService 確認該 request 的 ip 是否被 block 。
@Component public class MyAuthenticationProvider implements AuthenticationProvider { @Autowired private MyUserDetailsService myUserDetailsService; @Autowired private LoginAttemptService loginAttemptService; public Authentication authenticate(Authentication authentication) throws AuthenticationException { WebAuthenticationDetails wad = (WebAuthenticationDetails) authentication.getDetails(); String userIPAddress = wad.getRemoteAddress(); String username = authentication.getName(); String password = (String) authentication.getCredentials(); if(loginAttemptService.isBlocked(userIPAddress)) { throw new LockedException("This ip has been blocked"); } UserDetails user = myUserDetailsService.loadUserByUsername(username); if(user == null){ throw new BadCredentialsException("Username not found."); } if (!Password.encoder.matches(password, user.getPassword())) { throw new BadCredentialsException("Wrong password."); } Collection<? extends GrantedAuthority> authorities = user.getAuthorities(); return new UsernamePasswordAuthenticationToken(user, password, authorities); } public boolean supports(Class<?> authentication) { return true; } }
實作處理驗證失敗的後續行為,此次範例僅簡單拋回 403 異常。
@Component public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException { response.sendError(403, exception.getMessage()); } }
在 WebSecurityConfigurerAdapter 中設定認證時使用自定義的 MyUserDetailsService 、 MyAuthentcationProvider 和 MyAuthenticationFailureHandler
@Configuration @EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired DataSource dataSource; @Autowired private UserRepository _userRepo; @Autowired private MyUserDetailsService myUserDetailsService; @Autowired private MyAuthenticationProvider myAuthenticationProvider; @Autowired private MyAuthenticationFailureHandler myAuthenticationFailureHandler; @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/", "/home", "/signin", "/index").permitAll() .anyRequest().authenticated() .and() .formLogin() .loginPage("/login") .permitAll() .failureHandler(myAuthenticationFailureHandler) .and() .logout() .permitAll() .and() .csrf().disable(); } @Override public UserDetailsService userDetailsServiceBean() throws Exception { return myUserDetailsService; } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(myUserDetailsService); auth.authenticationProvider(myAuthenticationProvider); } }
大功告成!事實上只要了解如何自定義認證過程,細節部分還需要是使用情境修改,例如 block list 是要存在 memory 裡面還是要存到外部 db;用 ip 當作 block list 的 key 還要用 username + ip 等等。
完整的 code 可以參考 github 。