Spring Security自定义异常页面(403、401)的完整解决方案

 更新时间:2026年06月14日 15:36:42   作者:知远漫谈  
在现代 Web 应用开发中,安全控制是不可或缺的一环,Spring Security 作为 Spring 生态中最成熟、最广泛使用的安全框架,为开发者提供了强大的认证与授权能力,本文将深入探讨如何在Spring Security中优雅地处理401和403异常,需要的朋友可以参考下

引言

在现代 Web 应用开发中,安全控制是不可或缺的一环。Spring Security 作为 Spring 生态中最成熟、最广泛使用的安全框架,为开发者提供了强大的认证(Authentication)与授权(Authorization)能力。然而,当用户访问受保护资源时,若未通过认证或权限不足,系统默认会返回 HTTP 状态码 401(Unauthorized)或 403(Forbidden),并展示一个简陋甚至空白的错误页面。这不仅影响用户体验,也显得不够专业。

因此,自定义异常页面成为提升应用安全性和用户体验的关键一步。本文将深入探讨如何在 Spring Security 中优雅地处理 401 和 403 异常,并引导用户到友好的自定义页面。我们将从基础概念出发,逐步构建完整的解决方案,涵盖配置方式、代码实现、最佳实践以及常见陷阱。

一、理解 401 与 403 的区别

在开始编码之前,我们必须清楚 401 Unauthorized403 Forbidden 的语义差异,因为它们触发的时机和处理逻辑完全不同。

401 Unauthorized(未认证)

  • 含义:用户尚未提供有效的身份凭证(如未登录、Token 无效、Session 过期等)。
  • 触发场景
    • 访问需要登录的页面但未登录;
    • 提供的 JWT Token 已过期或签名错误;
    • Basic Auth 的用户名/密码错误。
  • HTTP 行为:通常伴随 WWW-Authenticate 响应头,提示客户端进行认证。

403 Forbidden(无权限)

  • 含义:用户已成功认证(即身份合法),但没有访问该资源的权限
  • 触发场景
    • 普通用户尝试访问管理员专属接口;
    • 角色不匹配(如 ROLE_USER 尝试访问 @PreAuthorize(“hasRole(‘ADMIN’)”) 的方法);
    • 权限表达式(如 SpEL)计算结果为 false。
  • 关键点:403 的前提是用户已认证,只是权限不足。

小贴士:混淆 401 和 403 是常见错误。记住:401 = “你是谁?”;403 = “我知道你是谁,但你不能进。”

二、Spring Security 默认的异常处理机制

Spring Security 内部使用一系列 Filter 来拦截请求并执行安全检查。其中两个关键过滤器负责异常处理:

ExceptionTranslationFilter

  • 位于 Filter Chain 中间位置;
  • 捕获 AuthenticationException(触发 401)和 AccessDeniedException(触发 403);
  • 根据异常类型调用相应的处理器。

AuthenticationEntryPoint

  • 处理 AuthenticationException(即 401 场景);
  • 默认实现:Http403ForbiddenEntryPoint(仅返回 403,不推荐)或 LoginUrlAuthenticationEntryPoint(重定向到登录页)。

AccessDeniedHandler

  • 处理 AccessDeniedException(即 403 场景);
  • 默认实现:AccessDeniedHandlerImpl,直接返回 403 状态码。

 默认情况下,Spring Security 在 Web 应用中若未显式配置,会使用内置的简单响应(如纯文本 “Access Denied” 或空白页),这显然不适合生产环境。

三、自定义 401 处理:AuthenticationEntryPoint 实现

401 异常通常发生在用户未登录时尝试访问受保护资源。我们希望将其重定向到登录页,或返回 JSON 错误(适用于 API)。

3.1 Web 应用场景:重定向到登录页

对于传统的 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");
    }
}

3.2 REST API 场景:返回 JSON 错误

对于前后端分离的架构(如 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 处理:AccessDeniedHandler 实现

403 异常表示用户已登录但权限不足。处理方式同样分 Web 和 API 两种。

4.1 Web 应用:跳转到“无权限”页面

创建一个 /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)
    }
}

4.2 REST API:返回 JSON 格式的 403 错误

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 已正确配置(如日期格式、空值处理等),避免序列化异常。

五、在 Spring Security 配置中注册自定义处理器

有了自定义的 AuthenticationEntryPointAccessDeniedHandler,下一步是在 SecurityConfig 中注册它们。

5.1 Web 应用配置示例

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

5.2 REST API 配置示例

对于无状态 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 过滤器
    }
}

六、全局异常处理 vs Spring Security 异常处理

有开发者会问:能否用 @ControllerAdvice 统一处理 401/403?

答案是:不能完全替代

原因如下:

  • Spring Security 的异常(如 AccessDeniedException)在 Filter 层抛出,早于 Spring MVC 的 DispatcherServlet,因此 @ControllerAdvice 无法捕获。
  • AuthenticationEntryPointAccessDeniedHandler 是 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 vs API)

在混合应用中(既有 Web 页面又有 API 接口),如何根据请求类型自动选择不同的异常处理器?

方案:基于Content-Type或路径前缀判断

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

注意:此方案需谨慎设计判断逻辑,避免误判。

八、测试自定义异常页面

编写集成测试验证配置是否生效。

8.1 测试 403 场景(Web)

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

8.2 测试 401 场景(API)

@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 可快速模拟不同角色的用户,极大简化安全测试。

九、常见问题与最佳实践

9.1 为什么自定义页面没生效?

  • 检查顺序:确保 .exceptionHandling() 配置在 HttpSecurity 链的最后部分(虽非强制,但逻辑清晰)。
  • 覆盖问题:不要同时配置 formLogin().loginPage()authenticationEntryPoint(),前者会覆盖后者。
  • 静态资源:确保 /error/403 页面本身无需认证,否则会陷入重定向循环。
http.authorizeHttpRequests(authz -> authz
    .requestMatchers("/error/**", "/static/**", "/login").permitAll()
    // ... 其他规则
);

9.2 如何记录异常日志?

在自定义 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());
    // ...
}

9.3 是否应该暴露详细错误信息?

  • 生产环境:避免在响应中暴露技术细节(如堆栈、内部类名);
  • 开发环境:可开启调试信息,便于排查。

可通过配置文件控制:

# 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;

十、国际化(i18n)支持

为异常页面添加多语言支持,提升用户体验。

10.1 定义消息资源

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=您没有权限查看此页面。

10.2 在 Controller 中注入 MessageSource

@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";
    }
}

十一、安全性考量

自定义错误页面时,需注意以下安全风险:

信息泄露

  • 避免在错误页面显示用户 ID、内部路径、数据库结构等敏感信息。
  • 示例:不要写 “User ‘admin’ does not have role ADMIN”,而应写 “Access denied”。

防止钓鱼

  • 错误页面应保持与主站一致的设计风格,避免被仿冒。
  • 使用 HTTPS 防止中间人篡改错误页面。

速率限制

  • 频繁触发 401/403 可能是暴力 破解攻击,建议结合 RateLimiter(如 Redis + Lua)进行限制。
Redis App User Redis App User alt [计数 > 10/分钟] [正常] 请求 /admin (无权限) INCR failed_access:/admin 当前计数 返回 429 Too Many Requests 返回 403 页面

十二、总结与展望

通过本文,我们系统性地学习了如何在 Spring Security 中自定义 401 和 403 异常页面。关键点回顾:

  • 区分 401 与 403:前者是未认证,后者是无权限;
  • 使用正确的扩展点AuthenticationEntryPoint 处理 401,AccessDeniedHandler 处理 403;
  • 适配应用场景:Web 应用重定向,API 返回 JSON;
  • 增强健壮性:添加日志、国际化、安全防护;
  • 充分测试:使用 @WithMockUserMockMvc 验证行为。

未来,随着微服务和云原生架构的普及,异常处理将更加标准化(如遵循 RFC 7807 Problem Details)。Spring Security 也在持续演进,例如对 Reactive 编程模型的支持(WebFlux),其异常处理机制略有不同,但核心思想一致。

安全不是功能,而是责任。一个友好的错误页面,不仅是用户体验的体现,更是系统安全防线的重要一环。

希望本文能助你在 Spring Security 的安全之路上走得更稳、更远!

以上就是Spring Security自定义异常页面(403、401)的完整解决方案的详细内容,更多关于Spring Security自定义异常页面的资料请关注脚本之家其它相关文章!

相关文章

  • springmvc模式的上传和下载实现解析

    springmvc模式的上传和下载实现解析

    这篇文章主要介绍了springmvc模式下的上传和下载实现解析,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2019-11-11
  • JAVA中wait()和notify()如何使用详解

    JAVA中wait()和notify()如何使用详解

    这篇文章主要介绍了JAVA中wait()和notify()如何使用的相关资料,wait和notify必须在同步方法或同步块中使用,并且调用对象必须一致,wait和sleep都可被interrupt唤醒,但wait会释放锁,而sleep不会,需要的朋友可以参考下
    2025-05-05
  • tio-http-server打包为二进制文件的实现及优势详解

    tio-http-server打包为二进制文件的实现及优势详解

    这篇文章主要为大家介绍了tio-http-server打包为二进制文件实现示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-12-12
  • Spring Batch远程分区的本地Jar包模式的代码详解

    Spring Batch远程分区的本地Jar包模式的代码详解

    这篇文章主要介绍了Spring Batch远程分区的本地Jar包模式,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2020-09-09
  • java jar包后台运行的两种方式详解

    java jar包后台运行的两种方式详解

    后台运行jar的方法有多种方法可以实现Java后台运行jar文件,下面介绍其中两种常见的方法,下面这篇文章主要给大家介绍了关于java jar包后台运行的两种方式,文中通过代码介绍的非常详细,需要的朋友可以参考下
    2024-07-07
  • intellij IDEA配置springboot的图文教程

    intellij IDEA配置springboot的图文教程

    Spring Boot是由Pivotal团队提供的全新框架,其设计目的是用来简化新Spring应用的初始搭建以及开发过程。接下来通过本文给大家介绍intellij IDEA配置springboot的图文教程,感兴趣的朋友一起看看吧
    2018-03-03
  • Default Methods实例解析

    Default Methods实例解析

    这篇文章主要介绍了Default Methods实例解析,介绍了默认方法的相关问题,以及与普通方法的区别,具有一定参考价值,需要的朋友可以了解下。
    2017-09-09
  • Java中的权重算法(如Dubbo的负载均衡权重)详解

    Java中的权重算法(如Dubbo的负载均衡权重)详解

    这篇文章主要介绍了Java中的权重算法(如Dubbo的负载均衡权重)详解,负载均衡,其含义就是指将负载进行平衡、分摊到多个操作单元上进行运行,例如FTP服务器、Web服务器、企业核心应用服务器和其它主要任务服务器等,从而协同完成工作任务,需要的朋友可以参考下
    2023-08-08
  • Java注解篇之@SneakyThrows示例详解

    Java注解篇之@SneakyThrows示例详解

    在Java编程中异常处理是一个重要的概念,为了简化异常处理过程,Lombok库提供了一个名为@SneakyThrows的注解,这篇文章主要介绍了Java注解篇之@SneakyThrows的相关资料,需要的朋友可以参考下
    2025-06-06
  • maven项目无法读取到resource文件夹的问题

    maven项目无法读取到resource文件夹的问题

    这篇文章主要介绍了maven项目无法读取到resource文件夹的问题,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2021-11-11

最新评论