多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

RuoYi匿名访问机制:PermitAllUrlProperties与@Anonymous注解源码解析

RuoYi匿名访问机制:PermitAllUrlProperties与@Anonymous注解源码解析 接手一个 RuoYi 二次开发项目后我做的第一件事就是翻SecurityConfig。凡是做过权限相关开发的朋友应该都有体会一个登录接口、一个验证码接口、一个第三方回调全都要往permitAll白名单数组里手工塞路径。接口一多这个数组变得越来越长每次新增匿名接口都得动一遍安全配置生怕改错一个把整个系统的认证给拆了。后来我在框架源码里翻了半天终于注意到一个叫PermitAllUrlProperties的配置类——它配合Anonymous注解直接把“匿名访问”这件事解耦了。这篇文章就围绕这个类的设计思路、源码实现和实战踩坑展开给正在做 RuoYi 二次开发或者自己搭 Spring Security 权限模块的朋友一个参考。先说清楚它解决什么问题在你不想让某个接口走登录认证时以前的做法是去SecurityConfig里加一行antMatchers(/xxx).permitAll()。现在只需要在 Controller 方法上打一个Anonymous注解启动时框架会自动扫描所有接口路径把这些路径收集成白名单并注入权限过滤链你甚至不用碰SecurityConfig。这不是什么黑科技但对“配置集中化”和“安全审计”来说价值非常明显。1. 为什么需要 PermitAllUrlProperties匿名白名单的进化史1.1 传统白名单配置的痛点先把时间拨回到传统写法。早期的 RuoYi 项目里如果你要放行一个接口基本操作是打开SecurityConfig在configure方法里找到这一段.antMatchers(/login, /register, /captchaImage).permitAll()这样做在最开始没什么问题三个默认接口清清楚楚。但项目进入业务迭代后各种需求就来了微信支付回调、短信服务回执、企业微信扫码登录的临时 code 换 token 接口、运营后台的匿名访客预约……每加一个就要往这个数组里补一个路径。这带来几个真实痛点。第一配置类被频繁修改。SecurityConfig是安全模块的核心任何改动都牵一发动全身一次不小心的手误可能让所有接口失去保护。我见过不止一个项目因为有人在antMatchers后面多写了一个/**导致整个系统全部裸奔。第二接口路径和安全配置分离。业务代码在 Controller 里匿名规则却躺在安全配置里两个地方隔了十万八千里。审计的时候想查“哪些接口是匿名的”得去翻配置文件的 git 历史费时费力。第三团队协作容易冲突。一个需求组改 Controller另一个需求组改 SecurityConfigGit 合并冲突几乎是必然的。这些痛点汇总起来核心问题就是一个本该属于接口自身“属性”的东西被放到了全局配置里。而PermitAllUrlProperties的思路就是把这个属性从全局配置中解放出来还给接口本身。1.2 Anonymous 注解的设计思路RuoYi 解决这个问题的方案很直接自定义一个注解Anonymous源码长这样package com.ruoyi.common.annotation; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * 匿名访问注解 * * author ruoyi */ Target({ ElementType.METHOD, ElementType.TYPE }) Retention(RetentionPolicy.RUNTIME) Documented public interface Anonymous { /** * 是否允许所有URL都匿名访问默认false */ boolean value() default false; }注意几个关键点。Target同时支持METHOD和TYPE说明这个注解既可以标在单个方法上也可以标在类上。标在方法上表示这个接口匿名标在类上意味着这个 Controller 里所有接口都匿名。value属性默认false含义是“这只是一个普通匿名接口”。如果把value设为true含义就变成了“允许所有 URL 都匿名访问”这个后文会详细讲它在PermitAllUrlProperties里如何生效。用注解代替全局配置本质上是把“免登录”定义为一个接口的声明式属性。你看到 Controller 代码里标了Anonymous就知道这个接口不走认证看到没有标注就知道它需要 token。这种“就近查看”的体验比去翻安全配置要舒服得多也天然规避了团队协作时的配置文件冲突。2. 源码拆解PermitAllUrlProperties 核心机制2.1 实现 ApplicationRunner 的启动时机先看PermitAllUrlProperties的类体结构和启动入口package com.ruoyi.framework.config; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.mvc.method.RequestMappingInfo; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import com.ruoyi.common.annotation.Anonymous; /** * 设置匿名访问URL * * author ruoyi */ Configuration public class PermitAllUrlProperties implements ApplicationRunner { Autowired private ConfigurableApplicationContext context; /** 匿名访问的URL集合 */ private final ListString permitAllUrls new ArrayList(); /** 是否允许全站匿名访问 */ private boolean permitAll false; public ListString getUrls() { return permitAllUrls; } public boolean isPermitAll() { return permitAll; } Override public void run(ApplicationArguments args) { // 扫描逻辑后文拆解 } }这个类最关键的设计就是实现了ApplicationRunner接口。ApplicationRunner是 Spring Boot 提供的启动回调接口它的run方法会在 Spring 容器刷新完成、应用即将对外提供服务之前执行。有人会问为什么不直接在构造方法或者PostConstruct里做扫描原因在于扫描依赖RequestMappingHandlerMapping这是 Spring MVC 中保存“URL 路径 - HandlerMethod”映射的核心组件。它需要等所有 Controller 都注册到容器里之后才会完成路由表的组装。如果在 Bean 初始化的早期阶段就去拿路由表很可能拿到一个不完整甚至还没初始化的空表。而ApplicationRunner的执行时机足够晚此时所有 Controller、RequestMapping、拦截器都已经被框架处理完毕路由表是完整可用的。所以 RuoYi 选择这个时机做一次“冷扫描”把结果提前缓存到permitAllUrls集合里后续SecurityConfig直接取用即可。另一个细节是它注入了ConfigurableApplicationContext而不是直接注入RequestMappingHandlerMapping。直接注入虽然能拿到 Bean但这个 Bean 的初始化时间可能比PermitAllUrlProperties还晚如果PermitAllUrlProperties在更早的时机被触发比如配置绑定、其他 Bean 提前调用就可能拿到空引用。注入容器再按需获取规避了这个时序问题。2.2 扫描 URL 映射的核心逻辑下面是run方法中扫描 URL 的全部核心逻辑Override public void run(ApplicationArguments args) throws Exception { // 从容器中获取 Spring MVC 的路由映射组件 RequestMappingHandlerMapping mapping context.getBean(RequestMappingHandlerMapping.class); // 获取所有 URL 路径 - Controller 方法的映射关系 MapRequestMappingInfo, HandlerMethod map mapping.getHandlerMethods(); map.keySet().forEach(mappingInfo - { HandlerMethod handlerMethod map.get(mappingInfo); // 1. 查找方法上的 Anonymous 注解 Anonymous methodAnonymous AnnotationUtils.findAnnotation(handlerMethod.getMethod(), Anonymous.class); if (methodAnonymous ! null) { // 将该方法对应的所有 URL 路径加入匿名白名单 SetString patterns mappingInfo.getPatternsCondition().getPatterns(); permitAllUrls.addAll(patterns); // 如果 Anonymous(value true)标记全站匿名 if (methodAnonymous.value()) { permitAll true; } } // 2. 查找类上的 Anonymous 注解 Anonymous typeAnonymous AnnotationUtils.findAnnotation(handlerMethod.getBeanType(), Anonymous.class); if (typeAnonymous ! null) { SetString patterns mappingInfo.getPatternsCondition().getPatterns(); permitAllUrls.addAll(patterns); if (typeAnonymous.value()) { permitAll true; } } }); }逐行解读这段逻辑。mapping.getHandlerMethods()返回的是MapRequestMappingInfo, HandlerMethod。RequestMappingInfo是 Spring MVC 对一次请求映射的完整描述里面包含了 URL 路径条件、请求方法GET/POST、参数条件、请求头条件等HandlerMethod则封装了真正处理这个请求的 Controller 方法以及它的 Bean 类型。mappingInfo.getPatternsCondition().getPatterns()是取出这个映射下的所有 URL 路径模式。这里要补充一个知识点一个 Controller 方法可以映射到多个路径例如Anonymous RequestMapping({/info, /detail}) public AjaxResult info() { ... }此时permitAllUrls会同时加入/info和/detail两个路径。另外如果路径里有 PathVariable比如/user/{id}那getPatterns()拿到的就是带花括号的模板路径。这个模板样式刚好能被 Spring Security 的antMatchers/requestMatchers识别所以不需要额外转义。注意这里使用的是AnnotationUtils.findAnnotation这是 Spring 提供的一个注解查找工具与普通的getAnnotation相比它的优势是支持组合注解、支持在桥接方法上查找、支持对接口默认方法的查找更符合 Spring 生态的注解语义。在方法级查找时传入的是handlerMethod.getMethod()在类级查找时传入的是handlerMethod.getBeanType()。这段扫描在整个应用生命周期里只执行一次。因为 URL 映射表在启动完成后就固定了除非你在运行期动态注册新的 Controller 方法极少数人有这种骚操作否则没有必要重复扫描。RuoYi 把它设计成一次性的启动扫描性能和结果都是最优的。2.3 类级与方法级 Anonymous 的双重处理上一小节展示了方法级和类级注解的匹配逻辑。这里细化一下两者叠加时的行为。当一个Anonymous同时出现在类和方法上时代码会执行两次判断但结果不会有冲突。只要任意一个注解被检测到URL 就会被加入白名单只要任意一个注解的值是truepermitAll就会被置为true。类级注解的场景非常好理解。比如你要做一个对外开放的数据字典查询服务整个 Controller 里的接口都不需要登录那直接在类上打一个Anonymous就行了Anonymous RestController RequestMapping(/open/dict) public class OpenDictController { ... }这比在类下每个方法都标一遍注解要省事得多。这里要特别提一下permitAll这个布尔标志。当一个接口标注的是Anonymous(value true)或者类上标注了Anonymous(value true)最终isPermitAll()就会返回true。它和“只放行几个 URL”是两种不同粒度的控制前者是“全局放行”后者是“局部放行”。「全局放行」在日常开发中应该尽量避免一般只在两个场景下使用一是研发环境前后端联调时临时关闭认证二是对接某些无法携带 token 的外部系统且你希望对方调用你们的任意接口。一旦打开整个系统就处于裸奔状态千万不要在生产环境长期开启。如果只看这段代码你可能会疑惑为什么类级注解也能被AnnotationUtils.findAnnotation(handlerMethod.getMethod(), ...)找到不对代码里类级是单独判断的用的是handlerMethod.getBeanType()。getBeanType()拿到的 Controller 类的 Class 对象所以类级注解能正常命中。到这里PermitAllUrlProperties的职责已经清楚了它负责在启动阶段收集所有携带Anonymous的 URL 模式并把“是否全局匿名”的标志位暴露出来。真正让这些 URL 生效还要看SecurityConfig怎么用它。3. 从配置类到过滤链与 SecurityConfig 的协作机制3.1 SecurityConfig 中的白名单注入光有 URL 集合还不够Spring Security 才是最终执行访问控制的角色。在 RuoYi 的SecurityConfig里这个配置类被注入并参与构建安全过滤链Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { Autowired private PermitAllUrlProperties permitAllUrlProperties; Override protected void configure(HttpSecurity httpSecurity) throws Exception { httpSecurity .csrf().disable() .authorizeRequests() // 将 Anonymous 收集到的 URL 全部放行 .antMatchers(permitAllUrlProperties.getUrls().toArray(new String[0])).permitAll() // 其余请求均需认证 .anyRequest().authenticated() .and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 省略异常处理、过滤器等配置 ; } }permitAllUrlProperties.getUrls().toArray(new String[0])这一步很关键。它把ListString转成了String[]因为antMatchers支持可变参数把数组传进去等价于展开成多个字符串。在 Java 里toArray(new String[0])是一种常见写法用空数组作为类型指示JVM 会按需扩容返回正确类型的新数组。授权规则的顺序也需要注意。Spring Security 的授权匹配是“先声明先匹配先生效”所以白名单规则必须放在anyRequest().authenticated()之前。一旦某个请求命中了前面的permitAll()后面的规则就不会再执行如果白名单放在后面前面的anyRequest().authenticated()已经把所有请求都拦截认证了白名单就形同虚设。新版 Spring Security 中antMatchers已经被标记为废弃推荐改用requestMatchers。RuoYi 较新的版本里这里写的是requestMatchers(...)原理是一样的这里为了对照老项目用antMatchers举例。无论哪个版本核心思路不变把PermitAllUrlProperties收集的 URL 作为匿名白名单注入过滤链。3.2 permitAll 标志位的真实作用当PermitAllUrlProperties.isPermitAll()返回true时SecurityConfig会走另一条分支if (permitAllUrlProperties.isPermitAll()) { // 全部请求放行不做认证 httpSecurity.authorizeRequests().anyRequest().permitAll(); } else { // 白名单 其他接口需认证 httpSecurity.authorizeRequests() .antMatchers(permitAllUrlProperties.getUrls().toArray(new String[0])).permitAll() .anyRequest().authenticated(); }这个分支很容易被忽略但它是满危险的。想象一下这个场景某位同事给一个 Controller 类标了Anonymous只是为了图省事想让类里大部分接口匿名。但他没注意到类上有其他敏感接口也没注意value属性默认是false——那isPermitAll()还是 false不会全局放行。可如果他在类上标了Anonymous(value true)那所有请求都会被放行整个系统的登录形同虚设。所以这个标志位在实战中最常见的误触发场景就是“类级注解 不假思索地把 value 设为 true”。我的建议是代码评审阶段看到Anonymous(true)或者类上标Anonymous一定要停下来仔细思考是否真的有必要。绝大多数场景方法级标注就足够了。3.3 完整流程串联一次匿名请求的旅程把上面这些串起来一次匿名请求从进入到返回的完整旅程是这样的请求先是到达 Spring Security 的过滤器链经过SecurityContextHolder、Session、CSRF 等过滤器的处理。随后请求进入授权环节Spring Security 拿当前请求的 URL 逐一匹配授权规则。它会先匹配.antMatchers(/login).permitAll()这类白名单规则。如果当前 URL 命中直接放行进入 Spring MVC 核心的DispatcherServlet。DispatcherServlet根据HandlerMapping找到对应的 Controller 方法执行并返回响应。对于非匿名请求比如/system/user/list授权环节不会匹配到任何permitAll()规则最终落在anyRequest().authenticated()上。此时过滤器发现请求头中没有有效的 token 或认证信息就会抛出认证异常最终被项目里的AuthenticationEntryPointImpl捕获并返回 401。整个过程的核心其实就是一张路由表和一份白名单路由表由 Spring MVC 维护白名单由PermitAllUrlProperties在启动时生成。两者在SecurityConfig中交汇形成最终的安全策略。4. 实战中如何用好 Anonymous 与 PermitAllUrlProperties4.1 正确使用姿势与常见误区我在实际接手项目和帮同事排查问题过程中总结出了几个高频误区。先把正确姿势写在前面想放行哪个接口就在哪个 Controller 方法上标Anonymous如果是整类放行才考虑标在类上尽量不要在注解上设置value true。误区一把Anonymous标在 Service 或者非 Controller 类上。由于扫描遍历的是RequestMappingHandlerMapping的映射表只收录 Controller 的 URL。除非这个 Service 里的方法被RequestMapping标注几乎不可能否则标了也白标。框架不会报错只是静默无效排查起来很费劲。误区二标了注解但忘记重启。PermitAllUrlProperties是启动时扫描开发环境下如果你用了热部署某些场景下类文件虽然更新了但容器没有重新执行run方法白名单就不会更新。最稳妥的做法是完整重启应用。误区三同路径不同 HTTP 方法的接口被一并放行。这里要详细说说。.antMatchers(/xxx).permitAll()这种写法匹配的是 URL 路径不区分 GET、POST、PUT、DELETE。假设你有一个Anonymous GetMapping(/data)但同一个 Controller 里还有一个PostMapping(/data)那么 POST/data也会被放行。因为 Spring MVC 注册了两个RequestMappingInfo路径都是/data而权限过滤链在白名单匹配时看到路径相同就直接放行了。这个坑非常隐蔽一旦踩中后果可能是某个写接口裸奔。规避方式匿名接口尽量用独立路径或者不要把 GET 和 POST 映射到同一个路径。误区四在SecurityConfig里手动重复配置同路径白名单。这虽然不报错但容易造成不一致。万一某天你想移除某个接口的匿名权限只删了SecurityConfig里的配置却忘了删方法上的Anonymous这个接口还是匿名的。建议统一用注解方式SecurityConfig 尽量不手工维护路径清单。4.2 实操案例给第三方回调接口加匿名结合一个我实际处理过的场景。当时项目接了一个支付平台回调支付平台服务器会直接 POST 一个通知请求到我们的接口回调请求当然带不了我们系统的 token。按老办法要改SecurityConfig加路径按Anonymous的方式操作非常简单。Controller 里的代码如下Anonymous PostMapping(/notify/pay) public AjaxResult payNotify(RequestBody PayNotifyBody body) { // 1. 校验签名 if (!signService.verify(body)) { return AjaxResult.error(签名校验失败); } // 2. 处理订单状态 return orderService.handlePayNotify(body); }加上Anonymous后启动项目这个接口就能直接访问了。为了方便验证我通常会在开发环境用 curl 模拟curl -X POST http://localhost:8080/notify/pay \ -H Content-Type: application/json \ -d {orderNo: 20240101000001, amount: 100}如果返回的是业务校验错误而不是 401 未认证说明匿名访问已经生效。这里多啰嗦一句Anonymous只是让这个接口免登录不等于让它免安全校验。第三方回调通常涉及资金或订单状态变更匿名之外一定要做签名校验。安全设计的原则是“永远不信任请求方”匿名接口的参数校验、签名校验、幂等处理反而要比普通登录接口更严格。4.3 踩坑记录与排查技巧速查表我在实际使用中遇到过下面表里的问题整理成速查表供大家对照表面现象可能原因解决方案标注了Anonymous但还是 401应用未重启扫描结果未更新完整重启别只依赖热部署标注了Anonymous但仍被拦截注解标错了类比如标在 Service 上确认注解在 Controller 方法或类上启动后大量接口全部不需要登录类级Anonymous(true)导致permitAll标志位被置为 true查找所有value true的注解改为方法级标注同路径的另一个方法被意外放行antMatchers只按路径匹配不区分 HTTP 方法匿名接口避免与业务接口共用路径白名单生效了但回调请求畸形返回匿名后走了业务逻辑参数或签名不对检查参数绑定和业务校验这类接口更容易暴露参数错误日志中看不到扫描记录版本未打印扫描日志或没有 Controller 标注解可自行在run方法加 log 打印 URL 集合排查技巧里我要额外推荐两个。第一个是启动时打印白名单。我改过PermitAllUrlProperties在run方法最后加一行日志log.info(匿名访问URL集合{}, permitAllUrls);启动时看一眼控制台就知道哪些路径被扫描到了。这个方法成本极低但能避免一半以上的“我明明标了为什么没用”类问题。第二个是如果二次开发的框架是基于 Sa-Token 而非 Spring Security现在很多中后台项目会把权限层替换为 Sa-Token那PermitAllUrlProperties的职责会迁移到 Sa-Token 的拦截器配置中。万变不离其宗核心就一句话在路由表构建完成后扫描带匿名标记的 URL再把结果注入访问控制组件。不同的只是 Spring Security 用antMatchersSa-Token 用SaInterceptor或者注解拦截器。技术支持上PermitAllUrlProperties还常出现在 RuoYi-Cloud、RuoYi-Vue-Plus、RuoYi-AI 这些衍生项目里。它们的实现大同小异有的版本会在扫描时额外处理类型为HandlerMethod的组合注解有的会支持Anonymous搭配自定义 security 属性。如果你在某个分支版本里看到这个类的代码和这里展示的有点不一样不要慌先找ApplicationRunner入口再看那两个AnnotationUtils.findAnnotation调用结构基本是一致的。真正理解了PermitAllUrlProperties的定位你会发现它本质上是“注解驱动配置”在安全领域的落地。把接口的匿名属性从集中式配置中抽离出来附着在接口声明上既降低了配置变更风险又提高了可读性。我个人在做权限模块时已经习惯了“先看注解、再看配置”的排查顺序。最后再分享一个小习惯每次新加匿名接口我都会顺手看一眼启动日志里的 URL 集合确认它确实进去了这条白名单。这种几秒钟的确认比在线上遇到 401 时再回头排查要划算得多。
返回列表