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 地址,這也會為通過代理服務器轉發的請求提供原始地址,在 X-Forwarded-For header 中。
我們也注意到,X-Forwarded-For header 是多值的,並且可以輕鬆地偽造原始 IP 地址。因此,我們不應該假設該 header 是可信的;相反,我們必須首先檢查它是否包含請求的遠程地址。否則,攻擊者可以設置與自己不同的 IP 地址在 header 的第一個索引中,以避免阻止自己的 IP。如果阻止了這些 IP 地址,攻擊者可以添加另一個 IP 地址,以此類推。這意味着他可以暴力破解 header IP 地址來偽造請求。
3. The 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() {
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 header 來獲取原始 IP;以下是此 header 的語法:
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阻止更多的元素。