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 || xfHeader.isEmpty() || !xfHeader.contains(request.getRemoteAddr())) {
            loginAttemptService.loginFailed(request.getRemoteAddr());
        } else {
            loginAttemptService.loginFailed(xfHeader.split(",")[0]);
        }
    }
}

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

注意,X-Forwarded-For header是多值的,很容易伪造原始 IP。因此,我们不应假设header是可信的;相反,我们必须先检查它是否包含请求的远程地址。否则,攻击者可以在标头的第一个索引处设置与自己的 IP 不同的 IP,以避免阻止自己的 IP。如果我们阻止其中一个 IP 地址,那么攻击者可以添加另一个 IP 地址,依此类推。这意味着他可以暴力破解标头 IP 地址来伪造请求。

3. LoginAttemptService

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

@Service
public class LoginAttemptService {

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

    @Autowired
    private HttpServletRequest request;

    public LoginAttemptService() {
        super();
        attemptsCache = CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.DAYS).build(new CacheLoader<String, Integer>() {
            @Override
            public Integer load(final String key) {
                return 0;
            }
        });
    }

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

    public boolean isBlocked() {
        try {
            return attemptsCache.get(getClientIP()) >= MAX_ATTEMPT;
        } catch (final ExecutionException e) {
            return false;
        }
    }

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

getClientIP() 方法实现:

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

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

X-Forwarded-For: clientIpAddress, proxy1, proxy2

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

所以,当我们进行身份认证时,只需检查计数器即可。

4. 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 {
        if (loginAttemptService.isBlocked()) {
            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);
        }
    }
}

问题来了,如何获取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;

    @Autowired
    private LocaleResolver localeResolver;

    @Autowired
    private HttpServletRequest request;

    @Autowired
    private LoginAttemptService loginAttemptService;

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

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

        ...
    }

}

7. 总结

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

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


« 上一篇: Baeldung周报7