在现代 Web 应用开发中,安全控制是不可或缺的一环。Spring Security 作为 Spring 生态中最成熟、最广泛使用的安全框架,为开发者提供了强大的认证(Authentication)与授权(Authorization)能力。然而,当用户访问受保护资源时,若未通过认证或权限不足,系统默认会返回 HTTP 状态码 401(Unauthorized)或 403(Forbidden),并展示一个简陋甚至空白的错误页面。这不仅影响用户体验,也显得不够专业。
因此,自定义异常页面成为提升应用安全性和用户体验的关键一步。本文将深入探讨如何在 Spring Security 中优雅地处理 401 和 403 异常,并引导用户到友好的自定义页面。我们将从基础概念出发,逐步构建完整的解决方案,涵盖配置方式、代码实现、最佳实践以及常见陷阱。
在开始编码之前,我们必须清楚 401 Unauthorized 和 403 Forbidden 的语义差异,因为它们触发的时机和处理逻辑完全不同。
WWW-Authenticate 响应头,提示客户端进行认证。小贴士:混淆 401 和 403 是常见错误。记住:401 = “你是谁?”;403 = “我知道你是谁,但你不能进。”

Spring Security 内部使用一系列 Filter 来拦截请求并执行安全检查。其中两个关键过滤器负责异常处理:
ExceptionTranslationFilter
AuthenticationException(触发 401)和 AccessDeniedException(触发 403);AuthenticationEntryPoint
AuthenticationException(即 401 场景);Http403ForbiddenEntryPoint(仅返回 403,不推荐)或 LoginUrlAuthenticationEntryPoint(重定向到登录页)。AccessDeniedHandler
AccessDeniedException(即 403 场景);AccessDeniedHandlerImpl,直接返回 403 状态码。默认情况下,Spring Security 在 Web 应用中若未显式配置,会使用内置的简单响应(如纯文本 “Access Denied” 或空白页),这显然不适合生产环境。
401 异常通常发生在用户未登录时尝试访问受保护资源。我们希望将其重定向到登录页,或返回 JSON 错误(适用于 API)。
对于传统的 MVC 应用(如 Thymeleaf、JSP),最常见的方式是重定向到 /login 页面。
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
throws IOException, ServletException {
// 重定向到自定义登录页
response.sendRedirect("/login?error=unauthenticated");
}
}
对于前后端分离的架构(如 Vue + Spring Boot),应返回结构化 JSON 而非重定向。
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
Map<String, Object> error = new HashMap<>();
error.put("timestamp", System.currentTimeMillis());
error.put("status", HttpServletResponse.SC_UNAUTHORIZED);
error.put("error", "Unauthorized");
error.put("message", "Authentication is required to access this resource.");
error.put("path", request.getRequestURI());
response.getWriter().write(objectMapper.writeValueAsString(error));
}
}
403 异常表示用户已登录但权限不足。处理方式同样分 Web 和 API 两种。
创建一个 /error/403 页面,展示友好提示。
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException)
throws IOException, ServletException {
// 重定向到自定义 403 页面
response.sendRedirect("/error/403");
}
}
对应的 Controller:
@Controller
public class ErrorController {
@GetMapping("/error/403")
public String accessDenied() {
return "error/403"; // 对应 templates/error/403.html (Thymeleaf)
}
}
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
public class RestAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException)
throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
Map<String, Object> error = new HashMap<>();
error.put("timestamp", System.currentTimeMillis());
error.put("status", HttpServletResponse.SC_FORBIDDEN);
error.put("error", "Forbidden");
error.put("message", "You do not have permission to access this resource.");
error.put("path", request.getRequestURI());
response.getWriter().write(objectMapper.writeValueAsString(error));
}
}
注意:确保 ObjectMapper 已正确配置(如日期格式、空值处理等),避免序列化异常。
有了自定义的 AuthenticationEntryPoint 和 AccessDeniedHandler,下一步是在 SecurityConfig 中注册它们。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/user/**").hasRole("USER")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.permitAll()
)
.logout(logout -> logout
.permitAll()
)
// 注册自定义 401 处理器
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.accessDeniedHandler(new CustomAccessDeniedHandler())
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
// 简化示例:内存用户
UserDetails admin = User.builder()
.username("admin")
.password("{noop}password") // 实际应使用加密
.roles("ADMIN")
.build();
UserDetails user = User.builder()
.username("user")
.password("{noop}password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(admin, user);
}
}
对于无状态 API(如 JWT),通常禁用 Session 和 Form Login:
@Configuration
@EnableWebSecurity
public class RestSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // 通常 API 禁用 CSRF
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
// 使用自定义 REST 异常处理器
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new RestAuthenticationEntryPoint())
.accessDeniedHandler(new RestAccessDeniedHandler())
)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(); // 自定义 JWT 过滤器
}
}
有开发者会问:能否用 @ControllerAdvice 统一处理 401/403?
答案是:不能完全替代。
原因如下:
AccessDeniedException)在 Filter 层抛出,早于 Spring MVC 的 DispatcherServlet,因此 @ControllerAdvice 无法捕获。AuthenticationEntryPoint 和 AccessDeniedHandler 是 Spring Security 专门设计的扩展点,必须通过 Security 配置注册。不过,你仍可以在 AccessDeniedHandler 中调用全局错误服务,实现日志记录、监控告警等:
@Service
public class GlobalErrorService {
public void logAccessDenied(String username, String path) {
// 记录日志、发送告警等
System.out.println("Access denied for user: " + username + " on path: " + path);
}
}
// 在 AccessDeniedHandler 中注入使用
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Autowired
private GlobalErrorService errorService;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException)
throws IOException, ServletException {
String username = SecurityContextHolder.getContext()
.getAuthentication().getName();
errorService.logAccessDenied(username, request.getRequestURI());
response.sendRedirect("/error/403");
}
}
在混合应用中(既有 Web 页面又有 API 接口),如何根据请求类型自动选择不同的异常处理器?
public class DynamicAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final RestAuthenticationEntryPoint restEntryPoint = new RestAuthenticationEntryPoint();
private final CustomAuthenticationEntryPoint webEntryPoint = new CustomAuthenticationEntryPoint();
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
throws IOException, ServletException {
if (isRestRequest(request)) {
restEntryPoint.commence(request, response, authException);
} else {
webEntryPoint.commence(request, response, authException);
}
}
private boolean isRestRequest(HttpServletRequest request) {
String uri = request.getRequestURI();
String accept = request.getHeader("Accept");
return uri.startsWith("/api/") ||
(accept != null && accept.contains("application/json"));
}
}
同理可实现 DynamicAccessDeniedHandler。
注意:此方案需谨慎设计判断逻辑,避免误判。
编写集成测试验证配置是否生效。
@SpringBootTest
@AutoConfigureTestDatabase
@AutoConfigureMockMvc
class SecurityWebTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(roles = "USER") // 模拟已登录的普通用户
void whenAccessAdminPage_thenRedirectTo403() throws Exception {
mockMvc.perform(get("/admin/dashboard"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/error/403"));
}
}
@Test
void whenAccessProtectedApiWithoutAuth_thenReturn401Json() throws Exception {
mockMvc.perform(get("/api/admin/data")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("Unauthorized"))
.andExpect(jsonPath("$.message").exists());
}
使用 @WithMockUser 可快速模拟不同角色的用户,极大简化安全测试。
.exceptionHandling() 配置在 HttpSecurity 链的最后部分(虽非强制,但逻辑清晰)。formLogin().loginPage() 和 authenticationEntryPoint(),前者会覆盖后者。/error/403 页面本身无需认证,否则会陷入重定向循环。http.authorizeHttpRequests(authz -> authz
.requestMatchers("/error/**", "/static/**", "/login").permitAll()
// ... 其他规则
);
在自定义 Handler 中添加日志:
private static final Logger logger = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
@Override
public void handle(...) {
logger.warn("Access denied for IP: {}, Path: {}, User: {}",
request.getRemoteAddr(),
request.getRequestURI(),
SecurityContextHolder.getContext().getAuthentication().getName());
// ...
}
可通过配置文件控制:
# application-prod.properties security.error.show-details=false # application-dev.properties security.error.show-details=true
在 Handler 中读取配置:
@Value("${security.error.show-details:false}")
private boolean showDetails;
为异常页面添加多语言支持,提升用户体验。
messages_en.properties:
error.403.title=Access Denied error.403.message=You don't have permission to view this page.
messages_zh.properties:
error.403.title=访问被拒绝 error.403.message=您没有权限查看此页面。
@Controller
public class ErrorController {
@Autowired
private MessageSource messageSource;
@GetMapping("/error/403")
public String accessDenied(Locale locale, Model model) {
model.addAttribute("title", messageSource.getMessage("error.403.title", null, locale));
model.addAttribute("message", messageSource.getMessage("error.403.message", null, locale));
return "error/403";
}
}
自定义错误页面时,需注意以下安全风险:
信息泄露
防止钓鱼
速率限制
RateLimiter(如 Redis + Lua)进行限制。通过本文,我们系统性地学习了如何在 Spring Security 中自定义 401 和 403 异常页面。关键点回顾:
AuthenticationEntryPoint 处理 401,AccessDeniedHandler 处理 403;@WithMockUser 和 MockMvc 验证行为。未来,随着微服务和云原生架构的普及,异常处理将更加标准化(如遵循 RFC 7807 Problem Details)。Spring Security 也在持续演进,例如对 Reactive 编程模型的支持(WebFlux),其异常处理机制略有不同,但核心思想一致。
安全不是功能,而是责任。一个友好的错误页面,不仅是用户体验的体现,更是系统安全防线的重要一环。
希望本文能助你在 Spring Security 的安全之路上走得更稳、更远!
以上就是Spring Security自定义异常页面(403、401)的完整解决方案的详细内容,更多关于Spring Security自定义异常页面的资料请关注本站其它相关文章!
您可能感兴趣的文章: