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上获取。