Перейти к основному содержимому

Предотвращение попыток аутентификации грубой силы с помощью Spring Security

· 4 мин. чтения

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) {
loginAttemptService.loginFailed(request.getRemoteAddr());
} else {
loginAttemptService.loginFailed(xfHeader.split(",")[0]);
}
}
}

Обратите внимание, как при сбое аутентификации мы сообщаем LoginAttemptService об IP-адресе, с которого произошла неудачная попытка. Здесь мы получаем IP-адрес из bean- компонента HttpServletRequest , который также дает нам исходный адрес в заголовке X-Forwarded-For для запросов, пересылаемых, например, прокси-сервером.

3. Слушатель AuthenticationSuccessEventListener

Давайте также определим AuthenticationSuccessEventListener , который прослушивает события AuthenticationSuccessEvent и уведомляет нас об успешной аутентификации:

@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]);
}
}
}

Обратите внимание, как — подобно прослушивателю ошибок, мы уведомляем LoginAttemptService об IP-адресе, с которого исходит запрос аутентификации.

4. Служба попытки входа в систему

Теперь давайте обсудим нашу реализацию 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 ; когда мы загружаем 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 , чтобы добраться до исходного IP-адреса; вот синтаксис этого заголовка:

X-Forwarded-For: clientIpAddress, proxy1, proxy2

Кроме того, обратите внимание на еще одну очень интересную возможность Spring — нам нужен HTTP-запрос, поэтому мы просто подключаем его.

Теперь, это круто. Нам нужно добавить быстрый слушатель в наш web.xml , чтобы это работало, и это значительно упрощает работу.

<listener>
<listener-class>
org.springframework.web.context.request.RequestContextListener
</listener-class>
</listener>

Вот и все — мы определили этот новый RequestContextListener в нашем файле web.xml , чтобы иметь возможность доступа к запросу из UserDetailsService .

6. Изменить AuthenticationFailureHandler

Наконец, давайте изменим наш CustomAuthenticationFailureHandler , чтобы настроить наше новое сообщение об ошибке.

Мы обрабатываем ситуацию, когда пользователь действительно блокируется на 24 часа — и мы сообщаем пользователю, что его IP заблокирован, потому что он превысил максимально допустимое количество неправильных попыток аутентификации:

@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 .