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 .