阻止暴力破解身份驗證嘗試

Spring Security
Remote
0
09:10 PM · Nov 29 ,2025

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阻止更多的元素。

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.