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