在Spring Security过滤器链中(如Cookie认证过滤器),若提前写入HTTP响应(如setStatus()+writeValue()),会导致ExceptionTranslationFilter因响应已提交而抛出ServletException,使AccessDeniedException等无法被正常处理。本文提供基于AuthenticationEntryPoint委托机制的标准解法,实现认证失败时返回结构化JSON错误,同时保持异常处理链完整。
在spring security过滤器链中(如cookie认证过滤器),若提前写入http响应(如`setstatus()`+`writevalue()`),会导致`exceptiontranslationfilter`因响应已提交而抛出`servletexception`,使`accessdeniedexception`等无法被正常处理。本文提供基于`authenticationentrypoint`委托机制的标准解法,实现认证失败时返回结构化json错误,同时保持异常处理链完整。
Spring Security 的异常处理具有严格的生命周期约束:所有认证(AuthenticationException)与授权(AccessDeniedException)异常必须由 ExceptionTranslationFilter 统一捕获并分发,该过滤器仅在响应尚未提交(response.isCommitted() == false) 时才可安全调用 AuthenticationEntryPoint 或 AccessDeniedHandler。一旦你在自定义过滤器(如 CookieAuthenticationFilter)中主动调用 response.setStatus() 或向输出流写入内容,响应即被标记为“已提交”,后续 ExceptionTranslationFilter 检测到此状态后将直接抛出 ServletException("response is already committed"),导致原本应被优雅处理的 AccessDeniedException 变成未捕获的容器级错误。
因此,绝不可在过滤器链中间手动写响应体——这不是代码风格问题,而是 Spring Security 架构设计的硬性要求。正确做法是遵循其委托模型:将认证失败的上下文信息“传递出去”,交由框架预留的标准化入口点(AuthenticationEntryPoint)统一处理。
✅ 推荐方案:请求属性 + 自定义 AuthenticationEntryPoint
该方案完全符合 Spring Security 设计哲学,解耦清晰、可测试性强,且不破坏默认过滤器链行为。
步骤 1:重构 CookieAuthenticationFilter —— 仅设属性,不写响应
@Component
public class CookieAuthenticationFilter extends OncePerRequestFilter {
private final AuthService authService;
private final ObjectMapper objectMapper;
// 使用常量避免字符串硬编码(提升可维护性)
public static final String COOKIE_AUTH_EXCEPTION_ATTR = "cookieAuthException";
public CookieAuthenticationFilter(AuthService authService, ObjectMapper objectMapper) {
this.authService = authService;
this.objectMapper = objectMapper;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authCookieValue = extractAuthCookieValue(request); // 实现略
if (authCookieValue == null) {
// 无有效凭证 → 触发未认证流程(由 EntryPoint 处理)
filterChain.doFilter(request, response);
return;
}
try {
UserDto user = authService.getUserFromAuthenticationToken(
new AuthenticationTokenValueDto(authCookieValue)
);
Authentication auth = new PreAuthenticatedAuthenticationToken(
user, authCookieValue, List.of()
);
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (CustomAuthException e) {
// ✅ 关键:仅存异常到 request 属性,不操作 response
request.setAttribute(COOKIE_AUTH_EXCEPTION_ATTR, e);
// ✅ 放行至 ExceptionTranslationFilter,由它触发 EntryPoint
}
filterChain.doFilter(request, response);
}
private String extractAuthCookieValue(HttpServletRequest request) {
return Arrays.stream(request.getCookies())
.filter(c -> "auth_token".equals(c.getName()))
.map(Cookie::getValue)
.findFirst()
.orElse(null);
}
}
⚠️ 注意:filterChain.doFilter(...) 必须始终被调用(即使发生异常),否则请求不会进入 ExceptionTranslationFilter,EntryPoint 将永远不会执行。
步骤 2:实现 AuthenticationEntryPoint —— 集中响应生成逻辑
@Component
public class CookieAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
public CookieAuthenticationEntryPoint(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
// 优先检查自定义认证异常(来自 CookieFilter)
CustomAuthException cookieEx = (CustomAuthException) request.getAttribute(
CookieAuthenticationFilter.COOKIE_AUTH_EXCEPTION_ATTR
);
if (cookieEx != null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getOutputStream(), Map.of(
"code", "AUTH_FAILED",
"status", HttpServletResponse.SC_UNAUTHORIZED,
"message", cookieEx.getMessage(),
"timestamp", Instant.now()
));
return;
}
// 兜底:其他未认证场景(如无 Cookie、Basic Auth 失败等)
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getOutputStream(), Map.of(
"code", "UNAUTHORIZED",
"status", HttpServletResponse.SC_UNAUTHORIZED,
"message", "Authentication required.",
"timestamp", Instant.now()
));
}
}
步骤 3:在 SecurityConfig 中注册 EntryPoint
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final CookieAuthenticationFilter cookieAuthenticationFilter;
private final CookieAuthenticationEntryPoint cookieAuthenticationEntryPoint;
public SecurityConfig(
CookieAuthenticationFilter cookieAuthenticationFilter,
CookieAuthenticationEntryPoint cookieAuthenticationEntryPoint) {
this.cookieAuthenticationFilter = cookieAuthenticationFilter;
this.cookieAuthenticationEntryPoint = cookieAuthenticationEntryPoint;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers("/un/**").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(e -> e
.authenticationEntryPoint(cookieAuthenticationEntryPoint) // ✅ 注册
)
.addFilterBefore(cookieAuthenticationFilter, BasicAuthenticationFilter.class);
return http.build();
}
}
? 为什么这个方案更优?
| 对比维度 | ❌ 错误方式(过滤器内写响应) | ✅ 正确方式(EntryPoint 委托) |
|---|---|---|
| 架构合规性 | 破坏 Spring Security 异常处理契约 | 完全遵循 ExceptionTranslationFilter 设计意图 |
| 可扩展性 | 每个自定义过滤器需重复写响应逻辑 | 所有认证失败路径复用同一 EntryPoint,逻辑集中 |
| 可测试性 | 难以单元测试响应写入(需 Mock Servlet API) | EntryPoint 可独立注入 ObjectMapper 进行纯逻辑测试 |
| 错误覆盖 | 仅覆盖 CustomAuthException,遗漏其他 AuthenticationException | 自动兼容 BadCredentialsException、LockedException 等全部子类 |
| 日志与监控 | 异常在过滤器中“静默吞掉”,丢失堆栈与指标 | 异常经标准入口点,可统一埋点、审计、接入 Sentry 等 |
? 补充建议
- 若还需处理授权失败(如 AccessDeniedException),同理实现 AccessDeniedHandler 并通过 .accessDeniedHandler(...) 注册;
- 对于 JSON 响应结构,建议定义统一响应体类(如 ApiResult
),替代 Map.of(...),提升类型安全与前端兼容性; - 生产环境应添加 @Slf4j 在 EntryPoint 中记录认证失败事件(含 IP、User-Agent、时间戳),用于风控分析。
通过这一模式,你既获得了完全可控的 JSON 错误响应,又严格遵守了 Spring Security 的异常传播机制——这才是构建健壮、可维护 API 安全层的正确起点。











