1. 概述
在本快速教程中,我們將使用 Spring Security 實施一個基本的解決方案,以防止暴力破解身份驗證嘗試。
簡單來説,我們將記錄單個 IP 地址上失敗嘗試的數量。如果該 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 Bean 中獲取 IP 地址,該 Bean 還為通過例如代理服務器轉發的請求提供了原始地址,該地址位於 X-Forwarded-For 標頭中。
我們也注意到,X-Forwarded-For 標頭是多值的,並且可以輕鬆地偽造原始 IP 地址。因此,我們不應假設該標頭是可信的;相反,我們必須檢查它是否包含請求的遠程地址。否則,攻擊者可以在標頭的第一個索引中設置與自己不同的 IP 地址,以避免阻止自己的 IP 地址。如果我們在這些 IP 地址中阻止一個,攻擊者可以添加另一個 IP 地址,依此類推。這意味着他可以暴力破解標頭的 IP 地址,從而偽造請求。
3. 登錄嘗試服務 (LoginAttemptService)
現在,我們來討論一下我們的 LoginAttemptService 實現;簡單來説,我們記錄每個 IP 地址在 24 小時內錯誤的嘗試次數。該塊方法將檢查給定 IP 地址的請求是否超過了允許的限制。
@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 標頭來獲取原始 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);
}
}
}此外,請注意 Spring 還有一個非常有趣的特性:我們需要 HTTP 請求,因此我們只是將其硬編碼進去。
現在,這很酷。我們需要在我們的 web.xml 中添加一個快速監聽器,才能使其生效,並且這使得事情變得更加簡單。
<listener>
<listener-class>
org.springframework.web.context.request.RequestContextListener
</listener-class>
</listener>大致就是這樣——我們已經定義了這個新的 RequestContextListener 在我們的 web.xml 中,以便能夠訪問來自 UserDetailsService 的請求。
5. 修改 AuthenticationFailureHandler
最後,讓我們修改我們的 CustomAuthenticationFailureHandler 以自定義我們的新錯誤消息。
我們處理用户實際被阻止 24 小時的情況——並且向用户告知其 IP 被阻止,因為他超過了允許的最大錯誤的身份驗證嘗試。在此類中,我們還會在每次失敗時檢查,以確定用户是否被阻止:
@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); } ... }}
6. 結論
重要的是要理解,這只是應對暴力破解密碼嘗試的初步步驟,但同時也意味着還有改進的空間。一套生產級別的暴力破解防禦策略可能比僅限IP阻止包含更多元素。