在 Web 应用中,登录功能是用户与系统交互的重要入口。而登录失败处理则是保障系统安全、提升用户体验的关键环节。Spring Security6 为我们提供了灵活的机制来自定义登录失败的处理逻辑,本文将详细介绍如何在 Spring Security6 中实现这一功能。
在 Spring Security6 的默认配置下,当用户登录失败时(如用户名或密码错误),会被重定向到登录页面,并在 URL 中添加 error 参数(例如 /login?error)。此时,登录页面需要自行解析该参数并显示相应的错误信息,如下图:
这种默认处理方式在简单应用中可能足够,但在复杂场景下存在局限性,例如:
无法根据不同的失败原因显示个性化错误信息
难以实现登录尝试次数限制
不便于记录登录失败日志
无法提供友好的错误提示(如验证码错误、账户锁定等)
接下来,我们将介绍如何自定义登录失败处理,解决上述问题。
要实现自定义登录失败处理,我们需要创建一个实现 AuthenticationFailureHandler 接口的类,并在其中编写具体的处理逻辑。
AuthenticationFailureHandler 接口用于自定义处理用户认证失败的场景。该接口允许开发者在用户登录失败时执行自定义逻辑,如记录日志、显示友好错误信息、限制登录尝试次数等。
接口定义如下:
package org.springframework.security.web.authentication;
import java.io.IOException;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.core.AuthenticationException;
/**
* 用于处理身份验证尝试失败的策略。
* <p>
* 典型的行为可能是将用户重定向到身份验证页面(在表单登录的情况下),让他们可以再次尝试。
* 根据异常类型,可能会实施更复杂的逻辑。
* 例如,{@link CredentialsExpiredException}(凭证过期异常)可能会导致重定向到一个Web
* 控制器,该控制器允许用户更改密码。
*
* @author Luke Taylor
* @since 3.0
*/
public interface AuthenticationFailureHandler {
/**
* 当身份验证尝试失败时调用。
* @param request 发生身份验证尝试期间的请求。
* @param response 响应。
* @param exception 抛出的用于拒绝身份验证请求的异常。
*/
void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException;
}当用户认证失败时,Spring Security 会调用 onAuthenticationFailure() 方法。开发者可在此方法中实现自定义逻辑,如重定向、转发、返回 JSON 响应等。
onAuthenticationFailure() 方法参数说明:
request:当前的 HTTP 请求对象。
response:当前的 HTTP 响应对象。
exception:认证过程中抛出的异常,包含失败原因(如用户名错误、密码错误、账户锁定等)。
注意,Spring Security6 提供了多种 AuthenticationException 异常类的子类,常见的异常类如下:
异常类 | 异常类说明 |
BadCredentialsException | 用户名或密码错误 |
LockedException | 账户被锁定 |
DisabledException | 账户被禁用 |
AccountExpiredException | 账户已过期 |
CredentialsExpiredException | 密码已过期 |
AuthenticationServiceException | 认证服务异常(如数据库错误) |
以下是一个简单的示例:
package com.hxstrive.spring_security.customer;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.*;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
// 获取用户名(如果有)
String username = request.getParameter("username");
// 记录登录失败日志
System.err.println("用户 '" + username + "' 登录失败: " + exception.getMessage());
// 根据不同的异常类型处理登录失败
String errorMessage = getErrorMessage(exception);
// 将错误信息存储到session中
request.getSession().setAttribute("errorMessage", errorMessage);
// 重定向到登录页面
response.sendRedirect(request.getContextPath() + "/login?error");
}
// 根据不同的异常类型返回不同的错误信息
private String getErrorMessage(AuthenticationException exception) {
if (exception instanceof BadCredentialsException) {
return "用户名或密码错误";
} else if (exception instanceof LockedException) {
return "账户已被锁定,请联系管理员";
} else if (exception instanceof DisabledException) {
return "账户已被禁用,请联系管理员";
} else if (exception instanceof AccountExpiredException) {
return "账户已过期,请联系管理员";
} else if (exception instanceof CredentialsExpiredException) {
return "密码已过期,请重置密码";
} else {
return "登录失败,请稍后再试";
}
}
}上述代码实现了 Spring Security 的自定义登录失败处理器,用于在用户认证失败时提供更友好的错误反馈。
在 Spring Security 配置类中,将自定义的登录失败处理器配置到 formLogin 中,如下:
package com.hxstrive.spring_security.config;
import com.hxstrive.spring_security.customer.CustomAuthenticationFailureHandler;
import com.hxstrive.spring_security.customer.CustomAuthenticationSuccessHandler;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.ForwardAuthenticationFailureHandler;
import java.io.IOException;
import static org.springframework.security.config.Customizer.withDefaults;
/**
* Spring Security 配置类
* @author hxstrive.com
*/
@Configuration
public class SecurityConfig {
// 忽略 UserDetailsService 和 PasswordEncoder 实例
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
System.out.println("securityFilterChain()");
// csrf 设置
// csrfCustomizer.ignoringRequestMatchers(*) 表示所有请求地址都不使用 csrf
http.csrf(csrfCustomizer -> csrfCustomizer.ignoringRequestMatchers("*"))
// authorize.anyRequest().authenticated() 表示所有的请求都需要进行身份验证。
.authorizeHttpRequests(authorize -> authorize
// 配置登陆失败页面访问不需要鉴权,如果不配置,不会跳转到登录失败页面
.requestMatchers("/view/fail").permitAll()
.anyRequest().authenticated())
// formLogin() 用于配置表单登录功能
.formLogin(e -> e.loginProcessingUrl("/login") // 配置处理登陆请求到地址
// 自定义登陆页面地址
.loginPage("/view/login")
// 自定义登陆成功逻辑
.successHandler(new CustomAuthenticationSuccessHandler())
// 自定义登陆失败逻辑【快看这里】
.failureHandler(new CustomAuthenticationFailureHandler())
.permitAll())
// httpBasic() 启用 HTTP 基本认证,withDefaults() 表示使用默认的 HTTP 基本认证配置。
.httpBasic(withDefaults());
return http.build();
}
}在登录页面中,添加代码来显示存储在 session 中的错误信息,如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>自定义登录</title>
</head>
<body>
<h1>自定义登录页面</h1>
<!-- 显示错误信息 -->
<div th:if="${session.errorMessage}" style="color:red;">
<p th:text="${session.errorMessage}"></p>
<!-- 清除错误信息,避免刷新页面后仍然显示 -->
<script>
sessionStorage.removeItem('errorMessage');
</script>
</div>
<form action="/login" method="post">
<p>
<!-- 默认 name="username", 自定义参数 myUsername -->
用户名:<input type="text" name="username" placeholder="请输入用户名" />
</p>
<p>
<!-- 默认 name="password", 自定义参数 myPassword -->
密码:<input type="password" name="password" placeholder="请输入密码" />
</p>
<p>
<input type="submit" value="登录"/>
</p>
</form>
</body>
</html>重启项目,访问登陆页面,输入错误的用户名和密码,页面将显示登陆错误提示信息,如下图:

为了防止暴力破解密码,我们可以实现登录尝试次数限制功能。为了演示功能,我们将使用 JVM 内存来保存用户登陆失败次数信息(实际项目中,建议通过 Redis 保存登陆失败信息)。
代码如下:
package com.hxstrive.spring_security.customer;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.authentication.*;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class CustomAuthenticationFailureHandler2 implements AuthenticationFailureHandler {
/** 使用内存存储登录尝试次数(实际项目中建议使用Redis等分布式缓存) */
private static final Map<String, Integer> loginAttempts = new ConcurrentHashMap<>();
/** 最大允许连续登陆失败的次数 */
private static final int MAX_ATTEMPTS = 3;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
// 获取用户名(如果有)
String username = request.getParameter("username");
// 记录登录失败日志
System.err.println("用户 '" + username + "' 登录失败: " + exception.getMessage());
String errorMessage = "";
if(!StringUtils.hasText(username)) {
errorMessage = "用户名/密码不能为空";
sendRedirect(request, response, username, errorMessage);
return;
}
// 处理登录尝试次数
if (StringUtils.hasText(username)) {
loginAttempts.put(username, loginAttempts.getOrDefault(username, 0) + 1);
if (loginAttempts.get(username) >= MAX_ATTEMPTS) {
// 锁定账户逻辑(这里仅示例,实际应更新数据库)
errorMessage = "由于多次登录失败,账户已被锁定";
}
}
if(!StringUtils.hasText(errorMessage)) {
errorMessage = getErrorMessage(exception, username);
}
sendRedirect(request, response, username, errorMessage);
}
private void sendRedirect(HttpServletRequest request, HttpServletResponse response, String username,
String errorMessage) throws IOException {
// 将错误信息和剩余尝试次数存储到session中
HttpSession session = request.getSession();
session.setAttribute("errorMessage", errorMessage);
if (StringUtils.hasText(username) && loginAttempts.containsKey(username)) {
session.setAttribute("remainingAttempts", MAX_ATTEMPTS - loginAttempts.get(username));
}
// 重定向到登录页面
response.sendRedirect(request.getContextPath() + "/login?error");
}
// 根据不同的异常类型返回不同的错误信息
private String getErrorMessage(AuthenticationException exception, String username) {
if (exception instanceof BadCredentialsException) {
int attempts = loginAttempts.getOrDefault(username, 0);
int remaining = MAX_ATTEMPTS - attempts;
if (remaining > 0) {
return "用户名或密码错误,您还剩" + remaining + "次尝试机会";
} else {
return "由于多次登录失败,账户已被锁定";
}
} else if (exception instanceof LockedException) {
return "账户已被锁定,请联系管理员";
} else if (exception instanceof DisabledException) {
return "账户已被禁用,请联系管理员";
} else if (exception instanceof AccountExpiredException) {
return "账户已过期,请联系管理员";
} else if (exception instanceof CredentialsExpiredException) {
return "密码已过期,请重置密码";
} else {
return "登录失败,请稍后再试";
}
}
/**
* 在登录成功时重置尝试次数
* @param username
*/
public static void resetAttempts(String username) {
if (username != null) {
loginAttempts.remove(username);
}
}
}同时,需要在登录成功处理器中调用 resetAttempts() 方法:
package com.hxstrive.spring_security.customer;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import java.io.IOException;
/**
* 自定义登陆成功处理逻辑
* @author hxstrive.com
*/
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
System.out.println("onAuthenticationSuccess()");
// 这里可以添加自定义逻辑,例如记录登录时间、根据用户角色跳转
String username = authentication.getName();
System.out.println("用户 " + username + " 登录成功");
// 重置登录尝试次数
CustomAuthenticationFailureHandler2.resetAttempts(username);
// 默认重定向到首页,可根据需求修改
response.sendRedirect(request.getContextPath() + "/view/success");
}
}运行项目,效果如下图:


注意,上面仅仅添加了账号锁定功能,还需要添加账号锁定后自定解锁到功能,如账号锁定 30 分钟后自动解锁,读者自行实现。
在实际应用中,记录登录失败日志对于安全审计非常重要。可以结合 SLF4J 或其他日志框架来记录详细的登录失败信息,如下:
package com.hxstrive.spring_security.customer;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.time.LocalDateTime;
@Component
public class LoggingAuthenticationFailureHandler implements AuthenticationFailureHandler {
private static final Logger logger = LoggerFactory.getLogger(LoggingAuthenticationFailureHandler.class);
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
String username = request.getParameter("username");
String ipAddress = request.getRemoteAddr(); // IP地址
LocalDateTime loginTime = LocalDateTime.now(); // 时间
// 记录详细的登录失败日志
logger.warn("登录失败 - 用户名: {}, IP地址: {}, 时间: {}, 原因: {}",
username, ipAddress, loginTime, exception.getMessage());
// 其他处理逻辑...
response.sendRedirect(request.getContextPath() + "/login?error");
}
}当然,你也可以通过数据库来保存登陆日志,读者自行尝试。
登陆失败转发 failureForwardUrl() 方法内部调用的是 failureHandler() 方法,如下图:

继续查看 ForwardAuthenticationFailureHandler 类的源码,如下图:

上述源码中,请求通过 request.getRequestDispatcher(this.forwardUrl).forward(request, response) 转发,并在 request 作用域中设置 Key 为 WebAttributes.AUTHENTICATION_EXCEPTION 的内容为异常对象。
WebAttributes.AUTHENTICATION_EXCEPTION 定义如下:

登陆失败重定向 failureUrl() 方法内部也是调用 failureHandler() 方法,如下图:

继续查看 SimpleUrlAuthenticationFailureHandler 类到源码,如下图:

上述代码,如果设置了 defaultFailureUrl,则执行重定向或转发到该地址,否则返回 401 错误代码。如果进行重定向或转发,将调用 saveException 来缓存异常,以便在目标视图中使用。