Spring Security在身份验证和授权过程中为我们完成了许多工作。暴力破解是Web应用程序上的常见攻击,恶意用户会尝试将密码猜测作为暴力破解。Spring安全性是一个灵活的框架,并提供扩展点来扩展或使用核心功能。Spring Security不提供任何现成的功能来进行暴力保护,但提供了一些我们可以使用的扩展点。
在本文中,我们将构建Spring Security暴力保护以处理暴力攻击。有多种处理这些攻击的选项。
- 某些失败的尝试后锁定帐户。
- 设备Cookie –锁定未知设备。
- 使用验证码可以防止自动攻击。
在本文中,我们将研究第一个选项。我们将保留每次失败和成功登录尝试的艰苦跋涉,如果连续失败的登录尝试增加了一定的阈值,我们将锁定/禁用该帐户,并让用户进行密码重置或任何其他步骤来响应该帐户。
本文是Spring Security初学者课程的一部分,您可以从GitHub存储库中下载源代码。
1. Spring安全认证事件
我们将使用Spring安全事件发布功能来构建我们的暴力保护服务。对于成功或失败的每个身份验证,Spring安全性都会发布AuthenticationSuccessEvent或AuthenticationFailureEvent。我们将使用此功能来构建Spring安全性的暴力保护。这是我们策略的高级工作流程。
- 我们将编写一个自定义身份验证失败事件侦听器。该侦听器将与基础服务一起使用,以保持尝试失败次数的迷航,并在超过该次数时将其锁定。
- 成功的身份验证侦听器可以重置任何失败的计数(我们会将失败的计数器重置为零)。
让我们构建AuthenticationFailureEventListner一个侦听特定事件的通知,并在任何身份验证失败的情况下通知我们。
package com.javadevjournal.core.security.event;
import com.javadevjournal.core.security.bruteforce.BruteForceProtectionService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationListener; import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent; import org.springframework.stereotype.Component;
import javax.annotation.Resource;
@Component public class AuthenticationFailureListener implements ApplicationListener < AuthenticationFailureBadCredentialsEvent > {
private static Logger LOG = LoggerFactory.getLogger(AuthenticationFailureListener.class);
@Resource(name = "bruteForceProtectionService") private BruteForceProtectionService bruteForceProtectionService;
@Override public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent event) { String username = event.getAuthentication().getName(); LOG.info("********* login failed for user {} ", username); bruteForceProtectionService.registerLoginFailure(username);
}
}
|
在此事件侦听器中,监听AuthenticationFailureBadCredentialsEvent,BruteForceProtectionService会将用户ID信息传递给我们,该信息将检查并在需要时禁用用户帐户。
与失败事件处理程序类似,Spring安全性还将在成功身份验证时发布事件,我们将创建一个自定义成功处理程序。该处理程序会将控件移交给BruteForceProtectionService,以重置失败的计数器。
@Component public class AuthenticationSuccessListener implements ApplicationListener < AuthenticationSuccessEvent > {
private static Logger LOG = LoggerFactory.getLogger(AuthenticationSuccessListener.class);
@Resource(name = "bruteForceProtectionService") private BruteForceProtectionService bruteForceProtectionService;
@Override public void onApplicationEvent(AuthenticationSuccessEvent event) { String username = event.getAuthentication().getName(); LOG.info("********* login successful for user {} ", username); bruteForceProtectionService.resetBruteForceCounter(username); } }
|
BruteForceProtectionService是Spring的安全蛮力保护的一部分,会执行以下任务:
- 对于每次失败的登录尝试,增加失败的计数器。
- 检查失败计数是否超过允许的最大配置。
- 如果计数器失败超过最大限制,则禁用该帐户。
- BruteForceProtectionService还将在成功登录后重置计数器。
@Service("bruteForceProtectionService") public class DefaultBruteForceProtectionService implements BruteForceProtectionService {
@Value("${jdj.security.failedlogin.count}") private int maxFailedLogins;
@Autowired UserRepository userRepository;
@Value("${jdj.brute.force.cache.max}") private int cacheMaxLimit;
private final ConcurrentHashMap < String, FailedLogin > cache;
public DefaultBruteForceProtectionService() { this.cache = new ConcurrentHashMap < > (cacheMaxLimit); //setting max limit for cache }
@Override public void registerLoginFailure(String username) {
UserEntity user = getUser(username); if (user != null && !user.isLoginDisabled()) { int failedCounter = user.getFailedLoginAttempts(); if (maxFailedLogins < failedCounter + 1) { user.setLoginDisabled(true); //disabling the account } else { //let's update the counter user.setFailedLoginAttempts(failedCounter + 1); } userRepository.save(user); } }
@Override public void resetBruteForceCounter(String username) { UserEntity user = getUser(username); if (user != null) { user.setFailedLoginAttempts(0); user.setLoginDisabled(false); userRepository.save(user); } }
@Override public boolean isBruteForceAttack(String username) { UserEntity user = getUser(username); if (user != null) { return user.getFailedLoginAttempts() >= maxFailedLogins ? true : false; } return false; }
protected FailedLogin getFailedLogin(final String username) { FailedLogin failedLogin = cache.get(username.toLowerCase());
if (failedLogin == null) { //setup the initial data failedLogin = new FailedLogin(0, LocalDateTime.now()); cache.put(username.toLowerCase(), failedLogin); if (cache.size() > cacheMaxLimit) {
// add the logic to remve the key based by timestamp } } return failedLogin; }
private UserEntity getUser(final String username) { return userRepository.findByEmail(username); }
public int getMaxFailedLogins() { return maxFailedLogins; }
public void setMaxFailedLogins(int maxFailedLogins) { this.maxFailedLogins = maxFailedLogins; }
public class FailedLogin {
private int count; private LocalDateTime date;
public FailedLogin() { this.count = 0; this.date = LocalDateTime.now(); }
public FailedLogin(int count, LocalDateTime date) { this.count = count; this.date = date; }
public int getCount() { return count; }
public void setCount(int count) { this.count = count; }
public LocalDateTime getDate() { return date; }
public void setDate(LocalDateTime date) { this.date = date; } } }
|
确保我们能够保存和更新计数以及禁用帐户,通过UserEntity和UserDetailsService实现。@Entity @Table(name = "user") public class UserEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String firstName; private String lastName; @Column(unique = true) private String email; private String password; private String token; private boolean accountVerified;
//new fields private int failedLoginAttempts; private boolean loginDisabled; } @Service("userDetailsService") public class CustomUserDetailService implements UserDetailsService {
@Autowired UserRepository userRepository;
@Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { final UserEntity customer = userRepository.findByEmail(email); if (customer == null) { throw new UsernameNotFoundException(email); } boolean enabled = !customer.isAccountVerified(); UserDetails user = User.withUsername(customer.getEmail()) .password(customer.getPassword()) .disabled(customer.isLoginDisabled()) .authorities("USER").build(); return user; } }
|
请记住,一旦禁用帐户,即使使用成功凭据进行的登录尝试也不会解锁该帐户。要求用户使用重置密码功能来解锁帐户。
在登录页面上显示错误
上面的配置可以确保在两次弹簧登录失败尝试后帐户都被锁定,这可能是由于Spring Security蛮力保护。我们还可能希望在我们的登录页面上显示自定义错误消息,让我们对自定义登录页面及其控制器进行一些更改将以下条目添加到messages.properties文件:
user.account.locked = Your account has been locked due to multiple failed login attempts.
|
一旦用户超过失败的登录尝试,我们将在登录页面上显示以上消息。下一步是对登录页面控制器进行一些更改。@Controller @RequestMapping("/login") public class LoginPageController {
public static final String LAST_USERNAME_KEY = "LAST_USERNAME";
@Resource(name = "customerAccountService") private CustomerAccountService customerAccountService;
@GetMapping public String login(@RequestParam(value = "error", defaultValue = "false") boolean loginError, @RequestParam(value = "invalid-session", defaultValue = "false") boolean invalidSession, final Model model, HttpSession session) {
String userName = getUserName(session); if (loginError) { if (StringUtils.isNotEmpty(userName) && customerAccountService.loginDisabled(userName)) { model.addAttribute("accountLocked", Boolean.TRUE); model.addAttribute("forgotPassword", new ResetPasswordData()); return "account/login"; } } }
final String getUserName(HttpSession session) { final String username = (String) session.getAttribute(LAST_USERNAME_KEY); if (StringUtils.isNotEmpty(username)) { session.removeAttribute(LAST_USERNAME_KEY); // we don't need it and removing it. } return username; } }
|
在登录控制器中做了一些重要的事情。
- 如果正在不同地处理登录错误请求参数。获取此参数后,我们检查是否锁定了用户帐户。
- 如果它锁定了用户帐户,我们会向客户显示不同的错误消息。
- 如果您查看getUserName()方法,我们将从会话中获取用户名。
默认情况下,一旦我们在登录控制器中收到控件,用户名将在请求中不可用。要显示自定义消息,我们需要用户名。我们正在使用Spring安全性失败处理程序将用户名存储在会话中。
我们只将用户名存储在会话中,并让默认的身份验证处理程序AuthenticationFailureHandler在身份验证失败时执行其工作:
import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException;
public class LoginAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
public static final String LAST_USERNAME_KEY = "LAST_USERNAME";
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { request.getSession().setAttribute(LAST_USERNAME_KEY, request.getParameter("username")); super.onAuthenticationFailure(request, response, exception); } }
|
在登录HTML中添加条件,以在前端显示自定义错误消息。我们在登录页面添加了以下条件。
<div th:if="${param.error!=null and accountLocked ==true}"> <div class="alert alert-danger"> <span th:text="#{user.account.locked}"/> </div> </div>
|