almost 5 years ago

最近在研究 Spting Boot 框架和一些 web 開發的基礎安全性問題,花了點時間了解如何實作預防暴力登入的機制。
參考這篇文章 Prevent Brute Force Authentication Attempts with Spring Security

主要邏輯為當一個 ip 嘗試登入失敗多次後,將該 ip 加入 block list 中一段時間,避免攻擊者可以無限次數的重複嘗試登入密碼。

該篇文章將驗證 request ip 寫在 UserDetailsService 中,而官網對該 interface 的定義為 "Core interface which loads user-specific data"。對比另一個 interface "AuthenticationProvider" 的定義 “Indicates a class can process a specific Authentication implementation”,我覺得更適合拿來實作 blocrequestk 的邏輯。
因此修改了部分原始碼內容,下面簡介如實作:

LoginAttemptService

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> blockList;

    public LoginAttemptService() {
        blockList = CacheBuilder.newBuilder().
                expireAfterWrite(bolckTimeMins, TimeUnit.MINUTES).build(new CacheLoader<String, Integer>() {
            public Integer load(String key) {
                return 0;
            }
        });
    }

    public void loginSucceeded(String key) {
        blockList.invalidate(key);
    }

    public void loginFailed(String key) {
        int attempts = 0;
        try {
            attempts = blockList.get(key);
        } catch (ExecutionException e) {
            attempts = 0;
        }
        attempts++;
        blockList.put(key, attempts);
    }

    public boolean isBlocked(String key) {
        try {
            return blockList.get(key) >= MAX_ATTEMPT;
        } catch (ExecutionException e) {
            return false;
        }
    }
}

AuthenticationSuccessEventListener

一個監聽登入成功事件的 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());
    }
}

AuthenticationFailureListener

一個監聽登入失敗事件的 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());
    }
}

MyUserDetailsService

實作一個 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");
            }
        }
    }
}

MyAuthenticationProvider

實作一個 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;
    }
}

SimpleUrlAuthenticationFailureHandler

實作處理驗證失敗的後續行為,此次範例僅簡單拋回 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
  • block list 的 key 值(本文是以 request ip)

完整的 code 可以參考 github

← swarm + interlock + nginx + redis 達到保有 session 的 HA 及 dynamic scaling 架構 用 Spring Boot 架構支援 http/2 的網站以及 Request and Response Multiplexing / Server Push 測試 →