☰
Spring Security AccessDeniedException全解析:排查与修复实战
2026/10/10 20:50:45 网站建设 项目流程

最近又收到一条这类报错:日志里一行org.springframework.security.access.AccessDeniedException: 不允许访问,前端同事盯着页面直挠头——“按钮都看得到,为什么点一下就被拦?”我接手之后翻了半小时配置,才意识到这行异常在 Spring Security 里压根不是“看起来那么单纯”的错误。它可能是授权规则写严了,可能是角色前缀不对,也可能只是 CSRF 冒名顶替,甚至是你自己写的过滤器在“帮倒忙”。这篇文章就把我实际排查这类问题的全过程拆开讲,从异常抛出的位置,到六条排查方向,再到修复方案怎么选,一步一步说清楚。

1. 先看现场:这类报错通常出现在哪几个场景

1.1 一通来自前端的反馈

先还原一下我遇到过的典型现场。项目是一个后台管理系统,前端用的 Vue,后端 Spring Boot 2.7 + Spring Security 5.8。用户登录后能正常看到菜单,但是点进“操作日志”页面时,接口直接返回 403,前端拿到的响应体是:

{ "timestamp": "2024-05-11T10:23:41.120+00:00", "status": 403, "error": "Forbidden", "path": "/api/aspectLog/list" }

后端日志里恰好就有开头那行AccessDeniedException: 不允许访问。注意,这个页面用户明明能看得到菜单,说明前端拿到的菜单接口是通的,但真正查数据时却被告知“不允许访问”。这种“页面能进、接口被拦”的场景,最容易让人误判成“是不是接口地址写错了”“是不是 token 过期了”。

其实这里已经能确定一件事:用户是已认证的,否则 Spring Security 会把它引导到登录流程,而不是返回 403。既然认证没问题,那就是授权阶段被拦了。授权被拦的原因很多,但绝大多数跑不出后面我要讲的六个方向。

1.2 报错的三种典型形态

同样一个AccessDeniedException,在不同项目里的表现差异很大。我把它归纳为三种形态,你拿到报错后先对号入座,能少走很多弯路:

形态表现常见原因
默认白标页浏览器里出现 Whitelabel Error Page,状态码 403未配置自定义 AccessDeniedHandler
统一 JSON 返回前后端分离项目返回 403 + 业务 code/message实现了 AccessDeniedHandler,但规则有问题
登录页反复跳转访问接口时被 302 到登录页,登录后还是跳回来认证信息丢失或匿名用户访问受保护资源

第一种和第二种相对好排查,问题集中在授权规则本身。第三种则要先回头查认证链,确认 token 有没有正确传到后端、SecurityContext 是否真正建立了认证信息,别一上来就改授权配置,方向容易跑偏。

1.3 第一反应应该做什么

我的习惯是,拿到报错先做三件事,顺序不能乱:

  1. 确认当前登录用户的身份和角色——通过日志打印SecurityContextHolder.getContext().getAuthentication(),看 authentication 是否为空、principal 是谁、authorities 里有哪些权限。
  2. 确认访问的 URL 命中了哪条安全规则——把logging.level.org.springframework.security=DEBUG打开,或者看授权决策日志,判断是否真的命中了hasRole/hasAuthority。
  3. 确认请求的方法和路径——是 GET 还是 POST?同样一个路径,GET 可能放行了,POST 却被 CSRF 挡掉,这类案例我遇到不止一次。

这三步做完,基本上能把问题缩小到“授权规则配置”还是“认证状态异常”两条路上。

2. 报错背后的机制:AccessDeniedException是在哪个环节被抛出来的

2.1 认证和授权,是两个不同的事

很多刚接触 Spring Security 的开发者会把“登录失败”和“不允许访问”混为一谈。我平时喜欢用一个类比解释:认证是门卫查身份证,看你是不是这个小区的人;授权是门卫查工牌,看你是哪个部门的、能不能进这栋楼的某层。

对应到代码里:认证由AuthenticationManager和AuthenticationProvider负责,解决的问题是“你是谁”;授权由AuthorizationManager(旧版本是AccessDecisionManager+ 投票器)负责,解决的问题是“你能干什么”。AccessDeniedException属于授权阶段的异常,它抛出来的前提是——你已经是一个“认识的人”了,但你的权限不够。

2.2 ExceptionTranslationFilter:那个决定要不要放行的“交警”

Spring Security 的过滤器链里有一个关键角色叫ExceptionTranslationFilter,它不负责授权本身,只负责捕获链上其他过滤器抛出的AccessDeniedException和AuthenticationException,然后决定下一步怎么办。

它的处理逻辑大致是这样:

  • 捕获到AccessDeniedException后,先看当前用户是不是匿名用户(或者认证信息是否完整)。
  • 如果未认证或匿名,就调用AuthenticationEntryPoint,通常是跳转到登录页或返回 401,引导用户去登录。
  • 如果已认证但权限不足,就调用AccessDeniedHandler,默认返回 403 并抛出AccessDeniedException。

问题来了:很多博客里会说“AccessDeniedException 会被 ExceptionTranslationFilter 处理”,但实际你看到的异常堆栈往往不是在这个 Filter 里打印的,而是授权过滤器(Spring Security 6.x 里是AuthorizationFilter,5.x 里是FilterSecurityInterceptor)抛出后,异常处理器处理失败或者在别的地方被重新抛出时才打印出来。所以排查的时候别纠结异常到底是谁打印的,而是顺着这条链去定位“是哪一条规则判定拒绝的”。

2.3 为什么说直接往上抛异常到Controller是不正常的

有一种情况需要注意:如果你在@ControllerAdvice里写了@ExceptionHandler(AccessDeniedException.class),并且发现它真的捕获到了这个异常,那说明这个异常不是从安全过滤器链里抛出来的。为什么?因为安全过滤器链在请求到达DispatcherServlet之前就已经执行完了,异常在链上就被ExceptionTranslationFilter消化掉了,根本到不了 Controller 层的异常解析器。

那什么情况下 ControllerAdvice 能捕获到?往往是你自己的业务代码里主动throw new AccessDeniedException("不允许访问"),比如在 Service 层做某个数据权限判断时手动抛出。这种异常属于业务异常,它传播路径和 Spring Security 过滤器链抛出的AccessDeniedException是两码事。

这个区别非常重要,因为它直接影响你的修复方案:如果所有人都往@ExceptionHandler里加AccessDeniedException的统一处理,反而可能把安全过滤器的真实判定逻辑掩盖掉,导致线上问题越来越难查。

3. 我的排查链路:六个方向逐个排除

3.1 先看URL授权规则:requestMatchers写对了吗

第一站看SecurityFilterChain里的授权规则。Spring Security 6.x 的写法是:

http.authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() );

这段配置里有三个隐藏的坑。

第一个坑是匹配顺序。authorizeHttpRequests里的规则是按声明顺序匹配的,一旦命中某条规则,后面的规则就不再参与判断。如果你把.anyRequest().authenticated()写在了requestMatchers("/api/admin/**").hasRole("ADMIN")前面,那么任何带认证信息的请求(包括普通用户)访问/api/admin/**都会因为命中authenticated()而通过授权——这反而更危险。反过来,如果你把更宽松的规则写在前面,又会把后面更严格的规则“短路”掉。

第二个坑是匹配器的语义。requestMatchers底层用的是PathPatternRequestMatcher还是AntPathRequestMatcher,取决于你的配置方式。比如requestMatchers("/api/**")不会匹配/api本身,/api/aspectLog/list才会匹配;而requestMatchers("/api")只匹配精确路径。更隐蔽的是,/api/**这样的写法在某些版本中也不会匹配/api/aspectLog/list这种多层路径之外的带斜杠的情况——总之,路径匹配规则比你想象中严格,排查时最好直接打开 DEBUG 日志看它到底匹配了哪条规则。

第三个坑是老项目升级的遗留代码。Spring Boot 2.7 之前很多人用的是antMatchers,升到 Spring Security 6 之后antMatchers被移除了,IDE 里会直接飘红。但如果你还在用 5.x,antMatchers和mvcMatchers的语义也略有差异,容易混淆。

3.2 再看方法级安全注解:注解生效了吗

如果 URL 规则看起来没问题,接口还是被拦,第二站检查方法级安全注解,也就是@PreAuthorize、@Secured、@RolesAllowed这几位。

这里有一个最常见的坑:注解写了,但没启用方法安全。

Spring Security 5.x 需要:

@Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig { }

Spring Security 6.x 改成了:

@Configuration @EnableMethodSecurity public class SecurityConfig { }

如果你没加这个注解,@PreAuthorize就是一堆安静的注释,根本不会执行。这类问题的典型表现是:接口“意外地”能访问——注意,不是报 403。

但还有一种更隐蔽的情况:注解生效了,可你写的方法名或权限字符串和实际登录用户的 authority 对不上。举个例子,我在一个项目里看到过:

@PreAuthorize("hasAuthority('admin')") @GetMapping("/api/aspectLog/list") public Result list() { ... }

而用户的权限是ROLE_ADMIN。hasAuthority('admin')要求 authority 里有一个值恰好等于admin,ROLE_ADMIN显然不等于admin,于是无论你怎么登录,都会被拒。这种问题靠肉眼很难发现,必须对照数据库里用户的实际角色进行核对。

3.3 角色前缀:hasRole和hasAuthority的差异

说到权限字符串,就不得不提 Spring Security 一个容易让人“翻车”的设计:角色前缀。

hasRole("ADMIN")在底层等价于hasAuthority("ROLE_ADMIN"),它会自动把传入的字符串拼上ROLE_前缀再去比对。反过来,hasAuthority("ROLE_ADMIN")则是直接比对完整的权限标识。

这意味着两件事:

  • 如果你的用户角色在数据库里存的是一串ADMIN这种不带前缀的值,而UserDetailsService在构造SimpleGrantedAuthority时也没有手动加ROLE_,那么hasRole("ADMIN")永远返回 false。
  • 如果你的权限设计走的是“权限点”模式,比如sys:user:list、sys:user:add,那应该用hasAuthority或hasAnyAuthority,不要用hasRole。

我在实际项目里的建议是:角色和权限分开设计。角色用hasRole判断(数据库里不存 ROLE_ 前缀,交给 Spring Security 拼),细粒度权限用hasAuthority判断,两者不要混着写。一旦既用了hasRole("sys:user:list")又用了hasAuthority("ROLE_ADMIN"),排查起来会非常头大。

3.4 CSRF拦截伪装成权限问题

这是一个特别容易误诊的方向。Spring Security 默认开启 CSRF 防护,对 POST、PUT、DELETE、PATCH 这类非安全方法会校验 CSRF token。如果你的前端没有把 token 放在请求头或表单里,后端会直接返回 403。

但这里有个迷惑性极强的点:这个 403 和授权失败的 403 长得几乎一样,唯一区别是响应体里的 message。Spring Security 默认的 CSRF 失败信息类似:

Invalid CSRF token found for http://localhost:8080/api/aspectLog/list

注意,日志里不一定有AccessDeniedException的完整堆栈,因为 CSRF 过滤器(CsrfFilter)自己就有AccessDeniedHandler,它会直接处理异常,不一定会把异常抛给ExceptionTranslationFilter去打印。这就是为什么很多同事拿着日志说“没看到 AccessDeniedException 啊”,其实问题根本不在授权。

排查方法很简单:看请求到底是什么方法。如果接口是 POST 而前端没带X-CSRF-TOKEN或X-XSRF-TOKEN(取决于你配置的CsrfTokenRepository),优先怀疑 CSRF。特别典型的场景是:前端页面登录后菜单 GET 接口全通,但所有 POST 都报 403,且只在“登录之后”才出现——因为 CsrfFilter 的 token 依赖于会话,一旦会话里的 token 和请求带的不一致,就会拦截。

不要一上来就无脑关闭 CSRF。如果项目是前后端分离且所有接口都不依赖会话,可以配置无状态 session 并关闭 CSRF;但如果你只是图省事csrf(csrf -> csrf.disable()),得想清楚安全边界在哪里。

3.5 自定义过滤器与全局异常处理互相干扰

接下来这个方向比较“进阶”,但它往往是老项目最容易踩的暗坑。

有人在过滤器链里加了自己的OncePerRequestFilter,在 filter 里面做了业务判断,比如检查请求参数里有没有某个标识,没有就直接:

throw new AccessDeniedException("不允许访问");

问题在于:你自定义的这个 Filter 在链上的位置如果不在ExceptionTranslationFilter的“管辖范围”内,比如你把它放在了ExceptionTranslationFilter之后,或者干脆用addFilterBefore加在了链的末尾,那么ExceptionTranslationFilter根本捕不到这个异常。异常会直接往外冒,最终被DispatcherServlet的异常解析兜住,返回的可能就是 500 而不是 403。

还有一种情况是双重处理:自定义 filter 抛了异常,@ControllerAdvice里也写了AccessDeniedException的处理器,结果异常被 Controller 层捕获并包装,此时你已经离开了 Spring Security 的安全上下文,Authentication信息可能都拿不到了。

排查这个方向的思路是:把日志级别调到 DEBUG,看异常到底是从哪个过滤器抛出来的,调用栈里有没有OncePerRequestFilter、doFilterInternal这些字样。如果有,说明问题十有八九出在你自己的过滤器逻辑里,而不是 Spring Security 的授权规则。

3.6 登录成功后的重定向触发了二次请求

最后这个方向比较“隐蔽”,我见过不止一次因为登录成功后的跳转地址踩坑。

场景是这样的:你配置了

http.formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/admin/home", true) );

用户登录成功后,浏览器会 302 到/admin/home。这个/admin/home本身也是一个受保护资源,如果当前用户的角色不满足它要求的规则,会在登录成功之后紧接着爆发AccessDeniedException。

表现形态很迷惑:用户确实登录成功了,session 也确实建立了,但页面始终打不开,一直 403。而且日志里那个AccessDeniedException看起来和登录完全无关。

另一种变体是登录成功后跳到了一个需要 POST 的地址,或者跳转到/再被转发到某个受保护页面,中途出现二次鉴权失败。排查时记得把defaultSuccessUrl和successForwardUrl的目标路径也纳入授权规则的检查范围。

4. 修复方案怎么选:不要在“加白名单”一条路上走到黑

4.1 方案A:精确调整授权规则

如果确认是授权规则配置问题,优先做“规则层面的修正”,而不是把接口一刀切到permitAll。比如实际业务里/api/aspectLog/list应当允许“日志管理员”和“系统管理员”访问,那就明确写:

.requestMatchers("/api/aspectLog/**").hasAnyRole("LOG_ADMIN", "SYSTEM_ADMIN")

同时确保数据库里用户的角色值在通过UserDetailsService加载时,能被正确映射为ROLE_LOG_ADMIN或ROLE_SYSTEM_ADMIN。

这里有个原则我一直强调:精确控制,只放行需要的接口,而不是放行整个 URL 段。很多人图省事把/api/**全部permitAll,结果各种越权问题接踵而至。授权规则宁可多写几条,也不要图省事一放一大片。

4.2 方案B:修正方法级校验逻辑

如果问题出在方法级安全注解上,先看两件事:

第一,有没有启用方法安全。Spring Security 6.x 用@EnableMethodSecurity,5.x 用@EnableGlobalMethodSecurity(prePostEnabled = true)。没启用就赶紧补上。

第二,注解里的表达式和你实际的权限体系是否一致。如果权限标识是sys:aspectLog:list这种权限点,就写@PreAuthorize("hasAuthority('sys:aspectLog:list')");如果是角色判断,就明确用hasRole并保证角色前缀一致。

修正时不要只在 Controller 方法上加注解,Service 层如果有同样的数据权限校验逻辑,也要一并检查。重点排查那些“看起来能用但权限字符串对不上”的情况。

4.3 方案C:自定义统一异常响应

前后端分离项目通常需要把 403 的响应体格式统一成自己的业务结构。正确做法是实现AccessDeniedHandler和AuthenticationEntryPoint,而不是在@ControllerAdvice里拦截异常。

举个例子:

@Component public class JsonAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":403,\"message\":\"抱歉,您没有权限访问该接口\"}"); } }

然后在 SecurityConfig 里装配:

http.exceptionHandling(exception -> exception .authenticationEntryPoint(jsonAuthenticationEntryPoint) .accessDeniedHandler(jsonAccessDeniedHandler) );

这里要特别提醒:如果业务代码里也会主动抛出AccessDeniedException,那你可能确实需要在@ControllerAdvice里加一个处理器兜底。但一定不要把它当作唯一方案,否则过滤链里抛出的安全异常根本不会走到这里。

4.4 选型之前先问自己的三句话

修复方案不是拍脑袋选的。我在改动配置前会先问自己三句话:

  1. 这个接口是真的应该被所有已登录用户访问,还是只应该被特定角色/权限访问?如果是后者,permitAll就是错误的解法。
  2. 前端拿到 403 之后要怎么处理?是要跳回登录页,还是弹出“无权限”提示?这决定了我要配置AuthenticationEntryPoint还是AccessDeniedHandler,以及两者返回的 HTTP 状态码是否要区分(401 vs 403)。
  3. 如果我现在把这个接口放开,最坏情况是什么?会不会导致越权访问敏感数据?这是安全底线,想清楚了再做。

没有通吃所有场景的方案,也没有“改一处就能一劳永逸”的银弹。安全配置的核心思路永远是:明确边界,再写规则。

5. 复盘与加固:让“不允许访问”不再成为疑难杂症

5.1 学会看DEBUG级别日志

排查这类问题最有效的手段就是打开安全相关的日志。在application.yml里加:

logging: level: org.springframework.security: DEBUG

然后重新请求一次,你会看到类似这样的关键日志:

AuthorizationFilter - Authorized filter invocation [GET /api/aspectLog/list] AuthorizationManager - authorized ... with authorization decision ...

这能直接告诉你请求命中了哪条规则、授权决策的结果是“允许”还是“拒绝”。比起盯着异常堆栈猜,看日志的定位效率高得多。

调试完记得把级别改回 INFO,DEBUG 日志量很大,线上开着会刷爆日志文件。

5.2 用测试固化权限行为

权限这种逻辑,光靠人工点页面验证远远不够。建议用@SpringBootTest+MockMvc把关键接口的授权行为固化下来,比如:

@SpringBootTest @AutoConfigureMockMvc class SecurityAccessTest { @Autowired private MockMvc mockMvc; @Test void adminCanAccessAspectLogList() throws Exception { mockMvc.perform(get("/api/aspectLog/list") .with(user("admin").roles("ADMIN"))) .andExpect(status().isOk()); } @Test void normalUserCannotAccessAspectLogList() throws Exception { mockMvc.perform(get("/api/aspectLog/list") .with(user("normal").roles("USER"))) .andExpect(status().isForbidden()); } }

这类测试的意义在于:以后谁改了授权规则,运行一遍测试就知道有没有破坏原有权限边界。我在项目里推广这个做法之后,因为误改权限规则导致的事故明显少了。

5.3 升级版本时最容易踩的隐藏变更点

最后聊一下版本升级带来的“隐藏炸弹”。如果你最近把项目从 Spring Boot 2.x 升到 3.x,下面几个变更点要特别注意:

  • antMatchers、mvcMatchers全部换成了requestMatchers,升级时 IDE 会提示,但很多人只改了 URL 字符串,忽略了匹配语义的变化。
  • WebSecurityConfigurerAdapter被废弃,配置方式变成了SecurityFilterChainBean。很多老教程的代码直接复制会编译不过,然后有人就硬改,反而把原有规则弄乱。
  • @EnableGlobalMethodSecurity换成了@EnableMethodSecurity,这个之前提过,少加一个注解,所有@PreAuthorize静默失效,接口“意外开放”比“意外拒绝”更可怕。
  • csrf、cors这些配置的 DSL 风格也变了,升级后最好重新审视一遍安全配置,而不是只保证“能编译通过”。

我的建议是:升级 Spring Security 大版本,一定要单独安排一个“安全回归测试”的排期,把认证、授权、CSRF、CORS、方法安全这几个维度都过一遍,别指望运行期自己发现。

这两年处理过不下十几起AccessDeniedException的报错,回过头看,绝大多数问题都出在“对机制理解不透 + 排查方向不聚焦”上。这个异常本身的含义很明确——安全框架在告诉你:当前用户已经认识,但权限不够。你只要沿着“认没认证、匹配哪条规则、角色前缀对不对、是不是 CSRF 冒名、异常是不是从过滤器链抛出来的”这个思路去查,基本都能在一个小时内定位到根因。别一上来就质疑框架、别无脑加白名单,先看清楚异常是谁抛的、在哪个环节抛的,再动手改。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询