1. 概述

在本快速入门教程中,我们将使用Spring Security实现一个简单的解决方案,以防止攻击者暴力破解用户密码

简单来说,我们将记录每个IP地址的登录失败次数,如果超过了一定的尝试次数,则将被禁止24小时。

2. AuthenticationFailureListener

首先我们定义一个AuthenticationFailureListener来监听AuthenticationFailureBadCredentialsEvent事件,当认证失败时通知我们:

@Component
public class AuthenticationFailureListener implements 
  ApplicationListener<AuthenticationFailureBadCredentialsEvent> {

    @Autowired
    private HttpServletRequest request;

    @Autowired
    private LoginAttemptService loginAttemptService;

    @Override
    public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent e) {
        final String xfHeader = request.getHeader("X-Forwarded-For");
        if (xfHeader == null) {
            loginAttemptService.loginFailed(request.getRemoteAddr());
        } else {
            loginAttemptService.loginFailed(xfHeader.split(",")[0]);
        }
    }
}

当认证失败时,我们通知LoginAttemptService记录登录失败的IP地址。这里我们使用HttpServletRequest 获取源IP,如果请求经过代理服务器转发,我们还需要通过X-Forwarded-For获取真实IP地址。

3. AuthenticationSuccessEventListener

我们再定义一个AuthenticationSuccessEventListener监听AuthenticationSuccessEvent事件。类似的,当登录成功时通知我们,并记录IP地址:

@Component
public class AuthenticationSuccessEventListener implements 
  ApplicationListener<AuthenticationSuccessEvent> {
    
    @Autowired
    private HttpServletRequest request;

    @Autowired
    private LoginAttemptService loginAttemptService;

    @Override
    public void onApplicationEvent(final AuthenticationSuccessEvent e) {
        final String xfHeader = request.getHeader("X-Forwarded-For");
        if (xfHeader == null) {
            loginAttemptService.loginSucceeded(request.getRemoteAddr());
        } else {
            loginAttemptService.loginSucceeded(xfHeader.split(",")[0]);
        }
    }
}

4. LoginAttemptService

现在,让我们讨论一下LoginAttemptService的实现。简单来说,我们将记录每个IP 24小时内登录失败次数:

@Service
public class LoginAttemptService {

    private final int MAX_ATTEMPT = 10;
    private LoadingCache<String, Integer> attemptsCache;

    public LoginAttemptService() {
        super();
        attemptsCache = CacheBuilder.newBuilder().
          expireAfterWrite(1, TimeUnit.DAYS).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;
        }
    }
}

注意,当认证失败时增加该IP的尝试次数,成功则重置该计数器。

从而看来,当我们进行身份认证时,只需检查计数器即可。

5. UserDetailsService

现在,让我们在自定义的UserDetailsService实现中,添加额外的检查逻辑:加载UserDetails时,首先需要检查此IP地址是否被禁用:

    @Service("userDetailsService")
    @Transactional
    public class MyUserDetailsService implements UserDetailsService {
     
        @Autowired
        private UserRepository userRepository;
     
        @Autowired
        private RoleRepository roleRepository;
     
        @Autowired
        private LoginAttemptService loginAttemptService;
     
        @Autowired
        private HttpServletRequest request;
     
        @Override
        public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
            String ip = getClientIP();
            if (loginAttemptService.isBlocked(ip)) {
                throw new RuntimeException("blocked");
            }
     
            try {
                User user = userRepository.findByEmail(email);
                if (user == null) {
                    return new org.springframework.security.core.userdetails.User(
                      " ", " ", true, true, true, true, 
                      getAuthorities(Arrays.asList(roleRepository.findByName("ROLE_USER"))));
                }
     
                return new org.springframework.security.core.userdetails.User(
                  user.getEmail(), user.getPassword(), user.isEnabled(), true, true, true, 
                  getAuthorities(user.getRoles()));
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }

下面时getClientIP()方法

private String getClientIP() {
    String xfHeader = request.getHeader("X-Forwarded-For");
    if (xfHeader == null){
        return request.getRemoteAddr();
    }
    return xfHeader.split(",")[0];
}

请注意,我们还需要添加额外的逻辑,以获取原始IP地址。大多数情况下不是必须的,但在某些网络场景下,如使用反向代理时,则需要考虑。

上述场景下,我们需使用X-Forwarded-For Header来获取原始IP。该请求头的语法如下:

X-Forwarded-For: clientIpAddress, proxy1, proxy2

问题来了,如何获取HTTP request? Spring提供了RequestContextListener,可以实现自动注入request对象。

我们需要在web.xml中配置:

<listener>
    <listener-class>
        org.springframework.web.context.request.RequestContextListener
    </listener-class>
</listener>

这样,我们就可以在UserDetailsService中注入并访问HttpServletRequest对象。

6. 修改AuthenticationFailureHandler

最后将我们修改CustomAuthenticationFailureHandler,以显示自定义错误提示。

当用户24小时内被禁止时,我们将通知用户IP被ban,因为他超过了最大失败尝试次数:

@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Autowired
    private MessageSource messages;

    @Override
    public void onAuthenticationFailure(...) {
        ...

        String errorMessage = messages.getMessage("message.badCredentials", null, locale);
        if (exception.getMessage().equalsIgnoreCase("blocked")) {
            errorMessage = messages.getMessage("auth.message.blocked", null, locale);
        }

        ...
    }
}

7. 总结

这是防止密码被暴力破解的第一步,很好,但还有提升的余地。实际场景,反暴力破解策略不仅仅依靠IP地址,还包括其他维度来阻止攻击。

本教程完整源代码,可从GitHub上获取。