作者:旷野说
关键词:Spring Security、Servlet Filter、403 异常、安全架构、过滤器链、口诀记忆
🕵️♂️ 三、“静默 403”不是玄学,而是可解的谜题
为搞明白 ExceptionTranslationFilter 的“温柔一刀”——它把所有安全异常都悄悄吞掉,换成一个干净的 403 响应。
对用户友好,对开发者“不友好”。
但好消息是:这个问题完全可解。下面是我总结的四步调试法,从“看到异常”到“精准定位”,再到“生产友好响应”,一气呵成。
✅ 第一步:先开 DEBUG 日志——让安全决策“说话”
这是成本最低、见效最快的方式。只需在 application.yml 中加一行:
logging:
level:
org.springframework.security: DEBUG
开启后,你会看到类似这样的日志:
DEBUG o.s.s.w.a.i.FilterSecurityInterceptor -
Secure object: FilterInvocation: URL: /api/v1/orders/create
Attributes: [authenticated]
DEBUG o.s.s.w.a.i.FilterSecurityInterceptor -
Previously Authenticated: User [name=admin, roles=[USER, ADMIN]]
DEBUG o.s.s.access.vote.AffirmativeBased -
Voter: WebExpressionVoter, returned: -1 👈 权限投票失败!
AccessDeniedException: Access is denied
💡 关键信息:
- 请求的 URL 是什么?
- 当前用户是谁?
- 权限校验在哪一步失败?
- 是
FilterSecurityInterceptor(URL 层)还是MethodSecurityInterceptor(方法层)抛出的?
✅ 这是日常开发的首选手段——无侵入、开箱即用。
✅ 第二步:自定义异常处理器——让 403 “会说话”
DEBUG 日志适合后台看,但前端也需要知道为什么被拒。
于是,我实现了两个处理器:
1. CustomAccessDeniedHandler(处理 403)
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private static final Logger log = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
@Override
public void handle(HttpServletRequest req, HttpServletResponse res, AccessDeniedException e) throws IOException {
String uri = req.getRequestURI();
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String user = auth != null ? auth.getName() : "anonymous";
// 📝 记录完整上下文,便于排查
log.warn("ACCESS DENIED | URI: {} | User: {} | Cause: {}", uri, user, e.getMessage());
// 💬 返回结构化错误,前端可友好提示
res.setStatus(403);
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write("""
{
"code": 403,
"message": "权限不足",
"detail": "当前用户无权访问此资源",
"timestamp": "%s"
}
""".formatted(Instant.now()));
}
}
2. CustomAuthenticationEntryPoint(处理 401)
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest req, HttpServletResponse res, AuthenticationException ex) throws IOException {
log.warn("UNAUTHENTICATED | URI: {} | Error: {}", req.getRequestURI(), ex.getMessage());
res.setStatus(401);
res.getWriter().write("""
{"code":401,"message":"请先登录","detail":"Token missing or invalid"}
""");
}
}
3. 在 SecurityConfig 中注册:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.exceptionHandling(e -> e
.accessDeniedHandler(customAccessDeniedHandler)
.authenticationEntryPoint(customAuthenticationEntryPoint)
)
.csrf().disable()
.authorizeHttpRequests(authz -> authz
.requestMatchers("/login").permitAll()
.anyRequest().authenticated()
)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
✅ 效果:
- 前端收到 带错误码和提示的 JSON,不再一脸懵;
- 后台日志留下 完整上下文,支持快速排查。
✅ 第三步:开发环境临时“放行异常”——看原始堆栈
有时候,连日志都不够用。我想直接看到异常堆栈!
于是,我在开发环境加了一个开关:
@Value("${app.security.debug:false}")
private boolean securityDebug;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// ... 其他配置
// 🔧 仅在生产关闭异常转换,开发环境直接抛异常!
if (!securityDebug) {
http.exceptionHandling(e -> e
.accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(authenticationEntryPoint)
);
}
return http.build();
}
启动时开启:
# application-dev.yml
app:
security:
debug: true
✅ 效果:
直接看到AccessDeniedException完整堆栈,一眼看出是 Web 层还是方法层拦截。
⚠️ 注意:此模式仅用于本地开发,切勿上线!
✅ 第四步:快速判断拦截层级——是 Filter 还是 AOP?
这是我总结的快速诊断表:
| 判断依据 | Filter 拦截(URL 权限) | AOP 拦截(方法权限) |
|---|---|---|
| Controller 是否执行? | ❌ 不执行 | ✅ 执行 |
| Service 方法是否进? | ❌ 不进 | ❌ 不进(但在 Controller 之后被拦) |
| 日志关键词 | FilterInvocation: URL: /xxx | Method: public void ... |
| 调试技巧 | Controller 第一行加日志 → 没输出 | Controller 日志有输出,Service 没进 → 方法级拦截 |
💡 口诀:
“403 无日志?查 Filter;进了 Controller 还 403?查 @PreAuthorize!”
🧠 终极口诀:调试 403 的黄金五步法
一开日志看过程,
二写 Handler 记上下文,
三用开关看堆栈,
四判层级分 Filter 与 AOP,
五记口诀不迷路!
通过这套组合拳,我终于把“神秘 403”变成了可追踪、可解释、可修复的普通 bug。
Spring Security 不再是黑盒,而是一条透明、可控的安全流水线。
安全,本该如此清晰。 🔐
1699

被折叠的 条评论
为什么被折叠?



