上周有个朋友跟我吐槽:后台管理系统的每个URL权限都写在注解里,产品经理隔三差五要调菜单权限,每次都得改代码重新发版,在微服务集群里折腾一次少说半小时。他说想做成“数据库里配一条记录,权限立刻生效”的效果,问我Spring Security 6到底该怎么搞。这个问题太典型了,几乎每个做后台系统的团队都会遇到,而且Spring Boot 3 + Spring Security 6这套新组合跟老版本差别不小,网上很多教程还停留在5.x时代,照搬过来根本编译不过。所以我把最近在项目里实践动态URL权限验证的完整思路整理出来,从原理到代码,从方案到坑点全部讲清楚,希望能帮到正被权限系统折磨的人。
1. 权限写死在代码里的痛,动态URL验证到底解决了什么问题
很多项目最初的权限设计都是“静态”的:用@PreAuthorize注解或者antMatchers().hasRole()把URL和角色的关系锁死在代码里。这种写法在权限模型固定、变更频率极低的小系统里没问题,但一旦业务复杂起来就难受了。
1.1 动态URL权限验证的真实应用场景
我这边遇到的需求大致分三类。第一类是运营后台的角色权限配置,管理员可以在界面上给不同角色勾选菜单和按钮权限,存储到数据库,要求保存后立刻生效,不能重启服务。第二类是多租户/多业务线的资源隔离,同一个URL路径在不同业务线下需要不同角色才能访问,这个规则放在代码里几乎没法维护。第三类是临时权限和灰度白名单,比如某个接口需要临时对某个用户或某个IP段开放,最好能在不发布的情况下动态调整。
这三类场景共同指向一个核心能力:URL和权限的映射关系必须可运行时变更。也就是权限决策的“规则”不来自代码硬编码,而是来自外部存储(数据库、Redis、配置中心),每次请求过来时动态读取、动态匹配、动态决策。
1.2 Spring Security 6跟5.x比到底变了什么
先说一个容易踩的认知坑:网上大量教程还在用WebSecurityConfigurerAdapter,这个类在Spring Security 5.7开始废弃,到6.0彻底移除。Spring Security 6的配置方式变成了基于组件的SecurityFilterChain,Lambda DSL风格成为主流。
更关键的是授权模型的变更。如果你是从5.x升级过来的,会发现FilterSecurityInterceptor这套老实现已经边缘化了,Spring Security 6默认走的是AuthorizationFilter,它的背后是全新的AuthorizationManager<T>抽象。原来你熟悉的AccessDecisionManager、SecurityMetadataSource、ConfigAttribute这套虽然还在兼容层里,但官方明确不建议新代码再用了。
// 5.x时代的写法(已经过时) http.authorizeRequests() .antMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated(); // Spring Security 6的Lambda DSL写法 http.authorizeHttpRequests(auth -> auth .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() );这个变化不是简单改个方法名。authorizeHttpRequests接收的是AuthorizeHttpRequestsConfigurer,它内部把每个规则封装成AuthorizationManager实例。这意味着我们可以完全绕开默认的AuthenticatedAuthorizationManager和AuthorityAuthorizationManager,直接注入一个自定义的AuthorizationManager<RequestAuthorizationContext>来处理所有URL的权限决策。
1.3 适合什么样的人阅读
如果你正在做基于Spring Boot 3的权限系统,或者手上有老项目要升级到Spring Security 6,或者仅仅是好奇动态权限怎么做更优雅,这篇文章都值得你看完。我会假设你有Spring Security的基础认知,知道过滤器链、认证流程大概是怎么回事,但不需要你手动实现过Filter或者SecurityFilterChain。代码层面我会给出完整的实现,你拿来改改就能跑。
2. 深入AuthorizationManager机制,别再用5.x的思维写6.0的代码
想做好动态URL权限,先得理解Spring Security 6的授权核心到底是什么。这一章把AuthorizationManager的来龙去脉掰开揉碎,你后面写代码会顺很多。
2.1 AuthorizationManager的结构和职责
AuthorizationManager<T>是一个函数式接口,定义非常简单:
@FunctionalInterface public interface AuthorizationManager<T> { AuthorizationDecision check(Supplier<Authentication> authentication, T object); default AuthorizationManager<T> and(AuthorizationManager<T> other) { return new AndAuthorizationManager<>(this, other); } }核心方法就一个check:第一个参数是Supplier<Authentication>,延迟获取当前认证信息;第二个参数是泛型T,对于URL权限来说就是这个请求的上下文。返回AuthorizationDecision,相当于裁判给出“允许”或“拒绝”的结论。
这里有个容易忽略的细节:AuthorizationDecision构造的时候传入true代表允许,false代表拒绝。如果直接返回null,Spring Security会认为是“弃权”,继续走下一个manager。利用这个特性可以做组合校验。
2.2 URL请求的AuthorizationManager到底长什么样
在authorizeHttpRequests内部,每个requestMatchers().xxx()都会生成一个具体的AuthorizationManager。比如.authenticated()底层是AuthenticatedAuthorizationManager,.hasRole("ADMIN")底层是AuthorityAuthorizationManager。这些manager被串联在RequestMatcherDelegatingAuthorizationManager中,它的工作原理是先找匹配的RequestMatcher,再执行对应的manager。
// RequestMatcherDelegatingAuthorizationManager的简化逻辑 AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext context) { for (Entry<RequestMatcher, AuthorizationManager<RequestAuthorizationContext>> entry : mappings) { if (entry.getKey().matches(context.getRequest())) { return entry.getValue().check(authentication, context); } } return denied; // 没有匹配到任何规则,默认拒绝 }这套机制给动态URL权限提供了完美切入点:我们不需要改变整体的决策流程,只需要在anyRequest()后面接一个自定义manager,把默认的“匹配写死规则”替换成“动态查库规则”。
2.3 自定义manager和旧的SecurityMetadataSource方案对比
5.x时代做动态URL权限,主流做法是实现FilterInvocationSecurityMetadataSource,从数据库加载ConfigAttribute列表,再配合AccessDecisionManager决策。这套流程繁琐不说,6.0里面AccessDecisionManager相关API已经标废弃,FilterSecurityInterceptor也不再是默认过滤器。
旧方案的逻辑分两步:先加载“这个URL需要什么角色”,再判断“当前用户有没有这个角色”。而AuthorizationManager把这两步合并成了一个动作:拿到当前请求,直接返回是否放行。这是设计上的简化,也意味着我们不用再维护两个组件,一个类搞定全部决策逻辑。
在新模型下: PermissionRuleManager根据request匹配出规则集合 ↓ 角色比对,得到AuthorizationDecision ↓ 返回给AuthorizationFilter执行我把这个核心链路画在文字里,你在代码落地时心里始终要装着这三步,后面所有的实现都是围绕这个链路展开的。
3. 方案一:数据库规则 + 自定义AuthorizationManager,生产首选
下面进入实战环节。第一种方案最直接也最常用,把URL权限规则存进数据库,每次请求进来由自定义的AuthorizationManager动态匹配,不依赖注解,规则变更立即生效。这套方案是我现在在线上跑的方案。
3.1 表结构设计和规则加载模型
先看数据库怎么设计。一个最简但够用的模型需要两张表:role(角色表)和permission_rule(权限规则表),再加上一个关联表role_permission_rule。核心的规则表长这样:
CREATE TABLE `permission_rule` ( `id` bigint NOT NULL AUTO_INCREMENT, `url_pattern` varchar(255) NOT NULL COMMENT 'URL模式,如 /api/user/**', `http_method` varchar(16) DEFAULT 'ALL' COMMENT '请求方法:GET,POST,PUT,DELETE,ALL', `description` varchar(255) DEFAULT '', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0禁用', `sort_order` int NOT NULL DEFAULT 0 COMMENT '排序字段,值小的优先匹配', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB COMMENT='URL权限规则表'; CREATE TABLE `role_permission_rule` ( `id` bigint NOT NULL AUTO_INCREMENT, `role_code` varchar(64) NOT NULL COMMENT '角色编码', `rule_id` bigint NOT NULL COMMENT '规则ID', PRIMARY KEY (`id`) ) ENGINE=InnoDB COMMENT='角色规则关联表';这里有几个设计重点说下。url_pattern存Ant风格模式或PathPattern,比如/api/order/**表示所有订单接口。http_method用来区分GET和POST等,因为很多时候同一个URL不同方法权限要求不同。sort_order字段特别关键,后面讲匹配顺序的时候你会明白为什么。
对应Java的实体类和数据加载服务我就不贴完整代码了,核心是提供一个接口,一次查出所有启用的规则,并聚合成这样的内存对象:
public class PermissionRule { private String id; private String urlPattern; private String httpMethod; private List<String> roleCodes; private int sortOrder; }3.2 核心类:自定义AuthorizationManager的完整实现
这是整个方案的心脏,直接上代码:
@Component public class DatabaseUrlAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> { private static final Logger log = LoggerFactory.getLogger(DatabaseUrlAuthorizationManager.class); private final PermissionRuleService permissionRuleService; public DatabaseUrlAuthorizationManager(PermissionRuleService permissionRuleService) { this.permissionRuleService = permissionRuleService; } @Override public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext context) { HttpServletRequest request = context.getRequest(); String uri = request.getRequestURI(); String method = request.getMethod(); Authentication auth = authentication.get(); // 未登录或匿名用户不通过 if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { log.debug("请求 {} 未认证,拒绝访问", uri); return new AuthorizationDecision(false); } // 规则匹配 List<PermissionRule> matchedRules = permissionRuleService.matchRules(uri, method); if (matchedRules.isEmpty()) { // 没有配置规则的URL,可以选择放行或拒绝,按需配置 return new AuthorizationDecision(false); } // 提取当前用户的所有角色 Set<String> userRoles = auth.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toSet()); // 判断是否满足任一规则的角色要求 for (PermissionRule rule : matchedRules) { if (rule.getRoleCodes().stream().anyMatch(userRoles::contains)) { return new AuthorizationDecision(true); } } log.debug("用户角色 {} 不满足 URL {} 的权限要求", userRoles, uri); return new AuthorizationDecision(false); } }这里我强调三个容易被忽略的点。
第一,为什么判断匿名用户要用instanceof AnonymousAuthenticationToken?很多人只写auth.isAuthenticated(),但AnonymousAuthenticationToken的isAuthenticated()返回也是true,不排除它的话未登录用户会被当成已认证处理,后面角色匹配必然失败,行为表现就是所有接口都返回403——排查起来非常费劲。
第二,规则匹配的顺序和优先级。假设数据库里配置了/api/**需要USER角色,同时配置了/api/admin/**需要ADMIN角色。请求/api/admin/info时,两条规则都能匹配。如果你的matchRules返回列表顺序不稳定,先匹配到宽泛规则并判定有权限,那就越权了。所以我刻意把sort_order设计出来,窄规则排前面,宽规则排后面,匹配时一旦命中窄规则就优先按窄规则决策。
第三,matchRules的实现不要每次请求都打数据库。规则表的变更频率很低,必须加缓存。这一点我在第六章单独展开,这里先记住结论:生产环境直接查库会让DB被打爆。
3.3 SecurityFilterChain接入方式和配置细节
定义好manager之后,接入过滤器链非常简单:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, DatabaseUrlAuthorizationManager urlAuthorizationManager) throws Exception { http .authorizeHttpRequests(auth -> auth // 完全公开的URL .requestMatchers("/login", "/captcha", "/actuator/health").permitAll() // 静态资源放行 .requestMatchers("/css/**", "/js/**", "/images/**", "/favicon.ico").permitAll() // 其余所有请求都交给动态权限管理器 .anyRequest().access(urlAuthorizationManager) ) .formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/index", true) .permitAll() ) .logout(logout -> logout.logoutUrl("/logout").permitAll()) .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**")); return http.build(); } }注意.access()方法接收一个AuthorizationManager<RequestAuthorizationContext>实例,这就是6.0新增的能力——把整个授权决策完全交给你自己掌控。.permitAll()放行的请求不会走到自定义manager里,所以那些SSO继续注册、静态资源、验证码接口就不要再配权限规则了,这条路是明确放开的。
3.4 边界情况处理:规则不存在、规则禁用、用户无角色
实际联调的时候,会发现业务上总有刁钻角度。我把几个边界情况的处理策略也放出来:
- 规则表里查不到当前URL的任何规则:我默认拒绝访问。这个策略更安全,因为漏配规则意味着接口等于“裸奔”,宁可先锁死再逐条放开。如果你嫌配置工作量太大,可以加个开关
permission.deny-if-no-rule=false,改成默认放行,但上线前一定评估安全风险。 - 规则存在但用户没有匹配角色:返回拒绝,最终由
AccessDeniedHandler处理,返回403。 - 用户角色为空:同样拒绝。尤其注意用第三方SSO登录的场景,有时候用户信息已经认证通过但角色没同步过来,这种情况日志要打清楚,方便排查。
- 登录用户访问自己无权限的URL:不要返回401,应该返回403。401代表未认证,前端会跳登录页,但用户明明已经登录了,跳登录页就是死循环。这个区分在Spring Security里是
AuthenticationEntryPoint(401)和AccessDeniedHandler(403)的职责差异。
4. 方案二:规则外部化存储与实时刷新,让权限变更秒级生效
方案一解决了“动态”的问题,但心里还有个坎:规则缓存刷新不够快,配置完不能生效怎么办?这就要聊第二种方案——把规则加载做成可刷新的,配合配置中心或Redis发布订阅,实现秒级生效。
4.1 为什么需要实时刷新机制
我在方案一里提到了缓存。但项目上线后,产品经理在小黑板上写了个新角色,让我给某个接口加个权限,说“两分钟后验收”。如果规则缓存TTL是10分钟,两分钟后规则根本没刷新,这体验肯定不行。所以真实生产环境需要主动刷新机制,让规则变更能即时推送到运行中的服务。
不引入外部中间件的做法是用ApplicationEventPublisher发布刷新事件:
public class PermissionRuleChangedEvent { private final LocalDateTime refreshTime; // getter / constructor ... } @Component public class PermissionRuleService { private volatile List<PermissionRule> ruleCache = Collections.emptyList(); private final ApplicationEventPublisher eventPublisher; @EventListener public void onRuleChanged(PermissionRuleChangedEvent event) { reload(); } public synchronized void reload() { List<PermissionRule> freshRules = loadFromDatabase(); this.ruleCache = freshRules; } public List<PermissionRule> matchRules(String uri, String method) { // 基于ruleCache做匹配,不查库 } }配合一个简单的管理接口,管理员修改完规则就调用一次:
@RestController @RequestMapping("/api/permission") public class PermissionRuleController { private final ApplicationEventPublisher eventPublisher; @PostMapping("/reload") public Result<Void> reloadRules() { eventPublisher.publishEvent(new PermissionRuleChangedEvent(LocalDateTime.now())); return Result.success(); } }如果你的配置中心是Nacos或Apollo,那就更顺了:把规则JSON放到配置中心,监听配置变更事件,ConfigChangeListener里调用reload()方法。Redis的方案则是用Pub/Sub或直接删缓存key触发下一次请求时重建缓存。核心思路一样的:外部信号驱动本地缓存失效重建。
4.2 并发与原子性:重建缓存时会不会出现规则不一致
很多人在写reload()时踩过并发坑。最直观的错误写法是:
public void reload() { this.ruleCache = null; // 先清空 this.ruleCache = loadFromDatabase(); // 再加载 }如果loadFromDatabase()耗时500ms,这期间有请求进来看到ruleCache是null,直接NPE。更稳的做法是“先构建新列表,再整体替换引用”,利用volatile保证可见性:
public synchronized void reload() { List<PermissionRule> freshRules = loadFromDatabase(); // 排序:sortOrder升序,保证窄规则优先 freshRules.sort(Comparator.comparingInt(PermissionRule::getSortOrder)); this.ruleCache = List.copyOf(freshRules); }由于ruleCache是volatile引用,替换是原子操作,请求要么看到旧的全量规则,要么看到新的全量规则,绝对不会看到中间状态。这是并发编程里的“Copy-on-Write”思路,对这种低频写、高频读的场景是完美的匹配。
4.3 多节点部署下的刷新一致性
实际线上大概率是多个实例组成集群。如果只有一个管理节点刷新了自己本地的规则缓存,其他节点还是旧规则,那就会出现“同一个用户请求A机器403、请求B机器200”的诡异现象。
解决方式取决于你的部署环境。最省事的是规则统一存数据库,每次请求都校验一次数据库时间戳,但这就回到性能问题了。更好的做法是借助Redis Pub/Sub广播刷新信号:
// 伪代码:发布端 redisTemplate.convertAndSend("permission:rule:refresh", "refresh"); // 消费端(每个Spring Security服务实例) @EventListener public void onRedisMessage(RedisMessage message) { if ("permission:rule:refresh".equals(message.getChannel())) { permissionRuleService.reload(); } }这样任意一个管理端触发刷新,所有服务实例都会收到消息并重建缓存。如果公司内部用了注册中心,也可以借助服务间事件总线或者简单的定时轮询(比如每隔30秒检查一次规则表的update_time),看你具体基础设施了。
4.4 和方案一如何组合使用
方案一和方案二不是二选一的关系,是兼容的。方案二本质是给方案一的缓存加了一个主动淘汰机制。我实际落地时用了层次化的结构:
- 规则原始数据在MySQL,管理后台写操作只操作MySQL;
- 服务本地用Caffeine缓存匹配结果;
- Redis Pub/Sub或者Nacos配置变更事件触发缓存失效;
- 万一消息丢了或者推送失败,再加一层分钟级兜底轮询。
在这种结构下,权限变更的平均生效时间能控制在秒级,同时数据库的压力几乎可以忽略不计。
5. 方案三:注解与方法级安全结合URL规则,搭建分级权限模型
前面两种方案都是针对URL维度做的。但大项目中,单纯的URL权限往往不够用:你可能需要校验某个按钮的方法级权限,或者某个用户的部门数据权限。Spring Security 6里的方法级安全可以作为URL动态权限的补充,形成一套完整的权限体系。
5.1 为什么不能只依赖URL规则
URL规则有个天然缺陷:粒度是按“路径”划分的,不是按“操作”划分的。同一个/api/order/saveURL,在业务上可能同时包含“创建订单”和“创建订单并自动审核通过”两种操作,其中自动审核通过是管理员才有的能力。这种业务级权限没法靠URL区分,只能在方法内部做二次校验。
再有就是数据范围:同一个“查询订单列表”接口,普通用户可能只能看自己创建的,部门经理能看整个部门的,老板能看全公司。这种数据级过滤,URL规则一层也覆盖不了。
所以我把权限体系拆成两层:URL层负责“能不能进这个入口”,方法级负责“进了之后能做什么操作”。它们不是替代关系,是互补关系。
5.2 Spring Security 6方法级安全配置方式
首先在配置类上开启方法级安全:
@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { // ... }@EnableMethodSecurity在6.x中替代了5.x的@EnableGlobalMethodSecurity。开启后,@PreAuthorize、@PostAuthorize、@Secure这些注解就能用了。
如果你想把校验逻辑做成动态的,可以直接在@PreAuthorize里调用Spring Bean方法:
@Service public class OrderService { @PreAuthorize("@permissionValidator.check('order:approve')") public void approveOrder(Long orderId) { // 审核订单逻辑 } @PreAuthorize("@permissionValidator.check(#userId, 'order:query')") public List<Order> queryOrders(Long userId) { // 查询订单逻辑 } }这里@permissionValidator是容器里的一个Bean,check方法内部可以去数据库或Redis查询当前用户是否有某个权限码。这样权限码跟角色之间的关系也是动态管理的,甚至可以做参数级别的判断。
对应的PermissionValidator实现:
@Component("permissionValidator") public class PermissionValidator { private final RolePermissionCache rolePermissionCache; /** * 校验当前登录用户是否拥有指定权限码 */ public boolean check(String permissionCode) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { return false; } // 从缓存中获取用户的所有权限码集合 Set<String> permissions = rolePermissionCache.getPermissionsByUsername(auth.getName()); boolean allowed = permissions.contains(permissionCode); log.debug("用户 {} 校验权限码 {},结果 {}", auth.getName(), permissionCode, allowed); return allowed; } }5.3 设计完整的分级权限模型
把URL动态规则和方法级权限组合起来,我最终落地了一个三层模型,可以参考一下:
| 层级 | 实现方式 | 控制粒度 | 典型场景 |
|---|---|---|---|
| 第一层:URL入口 | 自定义AuthorizationManager | URL路径 + HTTP方法 | 接口能不能访问,登录才行还是某角色才行 |
| 第二层:操作权限 | @PreAuthorize + 权限码 | 业务操作 | 订单审核按钮、导出的权限 |
| 第三层:数据权限 | @PostAuthorize / 手动过滤 | 数据行级 | 只能看自己部门的订单 |
URL层优先拦截最粗粒度的请求,方法级做第二道关卡。这样管理员在后台既能配菜单权限(URL层),也能配操作权限(方法级权限码),覆盖了绝大多数后台系统的权限诉求。
5.4 三种方案的选择建议
写到这里,一并说下三种方案怎么选。如果你要新起一个项目,或者旧项目正在大改权限模块,我建议直接采用方案一加方案二:数据库规则 + 自定义AuthorizationManager + 缓存主动刷新,这套最干净,适合绝大多数中后台系统。如果你有非常细颗粒度的操作权限需求,就在方案一/二的基础上叠加方案三的方法级安全。
如果只是给一个小型内部工具加个动态开关,不需要复杂角色模型,那方案二也可以简化成“配置文件里的规则 + 手动触发刷新”,不必上表结构设计。但不管怎么简化,核心的AuthorizationManager思路是不变的,你把它吃透了,后面换什么存储、刷不刷新,都是锦上添花的事。
6. 性能优化:规则缓存、匹配器复用和失败降级策略
动态URL权限最容易招黑的就是“性能差”——每次请求都扫一遍规则表那肯定慢啊。但实际上只要做对缓存和匹配优化,性能完全不是问题,甚至能比默认的静态配置更快。这章专门讲性能方案。
6.1 本地缓存与缓存刷新策略
最直接的是把规则全量加载到本地内存,查询时只做内存匹配。JVM内存缓存这块我强烈推荐Caffeine,性能好、淘汰策略灵活:
@Configuration public class CacheConfig { @Bean public Cache<String, List<PermissionRule>> permissionRuleCache() { return Caffeine.newBuilder() .maximumSize(1024) .expireAfterWrite(Duration.ofMinutes(5)) .build(); } }但有个细节需要注意:我缓存的是“匹配结果”还是“全量规则”?两种思路各有优劣。
- 缓存全量规则:内存占用和规则条数成正比,每次请求遍历规则匹配,时间复杂度O(N)。
- 缓存匹配结果:以
uri + method为key,缓存匹配到的规则列表。规则变了清空整个缓存。优势是每次请求直接命中,时间复杂度O(1),规则量大了也扛得住。
我这边最终采用了“匹配结果缓存 + 规则变化主动清空”的组合:
public List<PermissionRule> matchRules(String uri, String httpMethod) { String cacheKey = httpMethod + ":" + uri; List<PermissionRule> rules = localCache.getIfPresent(cacheKey); if (rules != null) { return rules; } // 未命中,遍历全量规则匹配 List<PermissionRule> matched = ruleCache.stream() .filter(rule -> matchRule(rule, uri, httpMethod)) .sorted(Comparator.comparingInt(PermissionRule::getSortOrder)) .toList(); localCache.put(cacheKey, matched); return matched; }规则更新事件里调用localCache.invalidateAll(),一秒钟后所有请求都会用新规则匹配。这个方案的主要收益在于:热点URL只承受一次规则遍历成本,后续全部是O(1)取缓存。
6.2 RequestMatcher的构建与复用
很多人在动态权限里手写PatternMatchUtils.simpleMatch判断URL,写多了会发现跟Spring的AntPathRequestMatcher行为不一致,比如/api/user/**匹配/api/user应该是true还是false?不同实现可能给出不同答案。所以建议还是复用Spring标准的RequestMatcher。
问题在于每次请求都创建一个AntPathRequestMatcher代价不小。更好的做法是构建规则时就把RequestMatcher生成好,存到内存对象里:
public class PermissionRule { private String urlPattern; private String httpMethod; private List<String> roleCodes; private int sortOrder; private RequestMatcher requestMatcher; // 构建时初始化 PermissionRule(String urlPattern, String httpMethod, List<String> roleCodes, int sortOrder) { this.urlPattern = urlPattern; this.httpMethod = httpMethod; this.roleCodes = roleCodes; this.sortOrder = sortOrder; // 只支持ALL和方法限定两种情况 if ("ALL".equalsIgnoreCase(httpMethod)) { this.requestMatcher = new AntPathRequestMatcher(urlPattern); } else { this.requestMatcher = new AntPathRequestMatcher(urlPattern, httpMethod); } } public boolean matches(HttpServletRequest request) { return requestMatcher.matches(request); } }这样每次请求时匹配规则只要调requestMatcher.matches(request),内部做字符串路径比较,性能非常高。
6.3 失败降级:依赖数据库出问题时的兜底策略
任何动态化方案都要考虑“规则数据获取不到怎么办”。我在生产环境遇到过数据库连接池被打满,导致权限规则加载接口超时的情况。如果此时直接抛异常,所有请求都会变成500,线上全挂。
合理的降级策略分成几档:
- 规则缓存还有值:直接用旧规则。哪怕规则是10分钟前的,也比请求直接失败好。
- 缓存为空且数据库不可用:走“安全默认值”,所有非白名单请求拒绝访问。宁可全部403,不能放行未授权。
- 记录告警日志:通知运维排查数据库问题。
降级代码可以放在加载规则入口处:
public List<PermissionRule> loadRulesWithFallback() { List<PermissionRule> cached = getLocalCache(); if (cached != null && !cached.isEmpty()) { return cached; } try { List<PermissionRule> freshRules = loadRulesFromDatabase(); setLocalCache(freshRules); return freshRules; } catch (Exception e) { log.error("加载权限规则失败,降级为拒绝所有请求", e); return Collections.emptyList(); // 空规则 => 全拒绝 } }6.4 性能测试的参考数据
拿我项目的实际压测数据给大家一个直观感受:部署在2核4G的容器上,规则表里1700条规则,用户角色10个,接口QPS压到1200的时候,动态权限校验的P99耗时在1.1ms左右,相比完全不校验的情况多花不到0.5ms。这性能表现足够支撑绝大多数业务系统了。如果你规则量上万且QPS非常高,可以让规则匹配算法用前缀树或者正则预编译,但那属于极端场景,一般用不上。
7. 我踩过的坑,每一个都值得你记下来
这一章专门聊排错和避坑。动态权限方案踩过的坑比想象中多,很多问题不跑到线上是发现不了的。
7.1 坑一:permitAll规则位置不对,导致动态规则不生效
很多人配置authorizeHttpRequests时,喜欢把所有requestMatchers写在一起:
.authorizeHttpRequests(auth -> auth .requestMatchers("/login", "/captcha").permitAll() .requestMatchers("/css/**", "/js/**").permitAll() .anyRequest().access(urlAuthorizationManager) )这个写法其实是对的。但有人会误以为anyRequest()后面的规则会“过滤掉”前面的放行,于是把.access()写在最前面,结果静态资源全部403。authorizeHttpRequests的规则匹配是从上到下,匹配到就短路,所以放行规则一定要在动态校验规则之前,而且anyRequest()必须放最后。这是个顺序敏感配置,你违背它就会踩坑。
7.2 坑二:AntPathRequestMatcher和PathPatternRequestMatcher的规则不一致
Spring Security 6默认使用PathPatternRequestMatcher还是AntPathRequestMatcher,取决于你的配置和Spring MVC的解析模式。这两者匹配规则有细微差别,最典型的是/**和/*的区别。在AntPath里,/api/*只匹配/api/user这种单层路径,不匹配/api/user/detail;/api/**匹配所有层级。在PathPattern里行为类似,但边界处理和尾斜杠的处理方式可能有差异。
如果某个规则在线上一直匹配不上,第一个检查点就是:你到底用的是哪种匹配器,规则里通配符写对没有。我自己的习惯是统一用AntPathRequestMatcher显式创建,不依赖框架默认值,这样行为最可控,也方便测试。
7.3 坑三:角色字符串的ROLE_前缀问题
Spring Security里hasRole("ADMIN")会自动给角色加上ROLE_前缀,变成ROLE_ADMIN,但hasAuthority("ADMIN")不会。我在自定义manager里提取用户角色时,拿到的是GrantedAuthority列表,如果认证时放到authorities里的是ROLE_ADMIN,而数据库里规则表存的是ADMIN,直接contains比对永远不通过。
解决方式很简单,统一规范:自定义manager里自己控制前缀。数据库规则里存的角色码如果有ROLE_前缀,匹配时自动去掉;没有的,对比时自动补上。写一个工具方法:
private boolean roleMatches(String ruleRole, Set<String> userRoles) { String normalizedRuleRole = ruleRole.startsWith("ROLE_") ? ruleRole.substring(5) : ruleRole; return userRoles.stream() .map(role -> role.startsWith("ROLE_") ? role.substring(5) : role) .anyMatch(normalizedRuleRole::equals); }7.4 坑四:匿名认证的判定,可不是isAuthenticated()返回true就算成功
这前面提过一次,但值得单独拿出来说。AnonymousAuthenticationToken是Spring Security里一个特殊的存在:它没有实际用户,却通过了“认证”。在自定义manager里如果你只调用auth.isAuthenticated(),匿名请求也会返回true。所以判断未登录时一定要加上auth instanceof AnonymousAuthenticationToken这个判断。
7.5 坑五:403还是401,前后端经常扯皮
很多前后端分离项目,前端拿到403就弹“页面无权限”,但真实情况可能是用户登录过期了。问题出在:未登录请求受保护接口时,Spring Security默认会返回什么呢?答案取决于异常入口配置。
- 如果你配置了
http.exceptionHandling的authenticationEntryPoint,未登录访问保护资源会走这个入口,通常是返回401。 - 如果你没配置,走了
loginPage跳转,前端拿到的可能是302,也会造成困惑。 - 已登录但权限不足,走的是
accessDeniedHandler,应该返回403。
实践中我建议前后端分离项目这样配置:
http.exceptionHandling(ex -> ex .authenticationEntryPoint((request, response, authException) -> { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或登录过期\"}"); }) .accessDeniedHandler((request, response, accessDeniedException) -> { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":403,\"msg\":\"无权限访问\"}"); }) );这样前端才能根据状态码精确区分“去登录”和“提示无权限”,避免交互上的混乱。
7.6 排错利器:开启Security调试日志
遇到棘手的权限问题,光靠肉眼读代码很容易漏。我是直接用日志定位。在application.yml里:
logging: level: org.springframework.security: TRACE com.yourcompany.permission: DEBUG开启TRACE后,过滤器链里每一步是哪个过滤器、认证对象是什么、授权决策是什么,都会打印出来。很多人害怕日志量大,但排错阶段这点信息量完全值得。配合自定义manager里打日志,能清楚地看到请求的URI、方法、用户角色、匹配到的规则,问题基本一览无余。
最后分享两个我在实际项目中沉淀的技巧
动态URL权限这块,做完一套方案只是开始,让方案能长期稳定运转才是关键。最后分享两个很小的经验,一个关于测试,一个关于运维排查。
权限规则涉及线上安全,一定要写测试。我之前犯过的错就是只测试“hasRole有权限”的正向用例,结果某个规则把匹配条件写错,导致管理员都被拒之门外。建议至少覆盖三组用例:规则匹配正确返回200、规则未匹配返回403、未登录返回401。把这些测试挂在CI上,后续每次改动规则表结构或者匹配逻辑,都能立刻发现回归。
另一个是在自定义manager里打印包含用户名校验日志,出了安全事件方便复盘。比如记录用户xxx访问URL yyy,命中规则zzz,决策allow/deny。这些日志平时看着不起眼,等线上出线上权限问题或者审计需求时就是救命稻草。注意日志里别打用户密码或token这些敏感信息,只打用户名、角色、路径即可。