ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Security 6动态URL权限实战:基于AuthorizationManager实现数据库驱动权限管理

Spring Security 6动态URL权限实战:基于AuthorizationManager实现数据库驱动权限管理 上周有个朋友跟我吐槽后台管理系统的每个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的配置方式变成了基于组件的SecurityFilterChainLambda DSL风格成为主流。更关键的是授权模型的变更。如果你是从5.x升级过来的会发现FilterSecurityInterceptor这套老实现已经边缘化了Spring Security 6默认走的是AuthorizationFilter它的背后是全新的AuthorizationManagerT抽象。原来你熟悉的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直接注入一个自定义的AuthorizationManagerRequestAuthorizationContext来处理所有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的结构和职责AuthorizationManagerT是一个函数式接口定义非常简单FunctionalInterface public interface AuthorizationManagerT { AuthorizationDecision check(SupplierAuthentication authentication, T object); default AuthorizationManagerT and(AuthorizationManagerT other) { return new AndAuthorizationManager(this, other); } }核心方法就一个check第一个参数是SupplierAuthentication延迟获取当前认证信息第二个参数是泛型T对于URL权限来说就是这个请求的上下文。返回AuthorizationDecision相当于裁判给出“允许”或“拒绝”的结论。这里有个容易忽略的细节AuthorizationDecision构造的时候传入true代表允许false代表拒绝。如果直接返回nullSpring Security会认为是“弃权”继续走下一个manager。利用这个特性可以做组合校验。2.2 URL请求的AuthorizationManager到底长什么样在authorizeHttpRequests内部每个requestMatchers().xxx()都会生成一个具体的AuthorizationManager。比如.authenticated()底层是AuthenticatedAuthorizationManager.hasRole(ADMIN)底层是AuthorityAuthorizationManager。这些manager被串联在RequestMatcherDelegatingAuthorizationManager中它的工作原理是先找匹配的RequestMatcher再执行对应的manager。// RequestMatcherDelegatingAuthorizationManager的简化逻辑 AuthorizationDecision check(SupplierAuthentication authentication, RequestAuthorizationContext context) { for (EntryRequestMatcher, AuthorizationManagerRequestAuthorizationContext 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) ) ENGINEInnoDB COMMENTURL权限规则表; 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) ) ENGINEInnoDB 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 ListString roleCodes; private int sortOrder; }3.2 核心类自定义AuthorizationManager的完整实现这是整个方案的心脏直接上代码Component public class DatabaseUrlAuthorizationManager implements AuthorizationManagerRequestAuthorizationContext { private static final Logger log LoggerFactory.getLogger(DatabaseUrlAuthorizationManager.class); private final PermissionRuleService permissionRuleService; public DatabaseUrlAuthorizationManager(PermissionRuleService permissionRuleService) { this.permissionRuleService permissionRuleService; } Override public AuthorizationDecision check(SupplierAuthentication 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); } // 规则匹配 ListPermissionRule matchedRules permissionRuleService.matchRules(uri, method); if (matchedRules.isEmpty()) { // 没有配置规则的URL可以选择放行或拒绝按需配置 return new AuthorizationDecision(false); } // 提取当前用户的所有角色 SetString 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()方法接收一个AuthorizationManagerRequestAuthorizationContext实例这就是6.0新增的能力——把整个授权决策完全交给你自己掌控。.permitAll()放行的请求不会走到自定义manager里所以那些SSO继续注册、静态资源、验证码接口就不要再配权限规则了这条路是明确放开的。3.4 边界情况处理规则不存在、规则禁用、用户无角色实际联调的时候会发现业务上总有刁钻角度。我把几个边界情况的处理策略也放出来规则表里查不到当前URL的任何规则我默认拒绝访问。这个策略更安全因为漏配规则意味着接口等于“裸奔”宁可先锁死再逐条放开。如果你嫌配置工作量太大可以加个开关permission.deny-if-no-rulefalse改成默认放行但上线前一定评估安全风险。规则存在但用户没有匹配角色返回拒绝最终由AccessDeniedHandler处理返回403。用户角色为空同样拒绝。尤其注意用第三方SSO登录的场景有时候用户信息已经认证通过但角色没同步过来这种情况日志要打清楚方便排查。登录用户访问自己无权限的URL不要返回401应该返回403。401代表未认证前端会跳登录页但用户明明已经登录了跳登录页就是死循环。这个区分在Spring Security里是AuthenticationEntryPoint401和AccessDeniedHandler403的职责差异。4. 方案二规则外部化存储与实时刷新让权限变更秒级生效方案一解决了“动态”的问题但心里还有个坎规则缓存刷新不够快配置完不能生效怎么办这就要聊第二种方案——把规则加载做成可刷新的配合配置中心或Redis发布订阅实现秒级生效。4.1 为什么需要实时刷新机制我在方案一里提到了缓存。但项目上线后产品经理在小黑板上写了个新角色让我给某个接口加个权限说“两分钟后验收”。如果规则缓存TTL是10分钟两分钟后规则根本没刷新这体验肯定不行。所以真实生产环境需要主动刷新机制让规则变更能即时推送到运行中的服务。不引入外部中间件的做法是用ApplicationEventPublisher发布刷新事件public class PermissionRuleChangedEvent { private final LocalDateTime refreshTime; // getter / constructor ... } Component public class PermissionRuleService { private volatile ListPermissionRule ruleCache Collections.emptyList(); private final ApplicationEventPublisher eventPublisher; EventListener public void onRuleChanged(PermissionRuleChangedEvent event) { reload(); } public synchronized void reload() { ListPermissionRule freshRules loadFromDatabase(); this.ruleCache freshRules; } public ListPermissionRule matchRules(String uri, String method) { // 基于ruleCache做匹配不查库 } }配合一个简单的管理接口管理员修改完规则就调用一次RestController RequestMapping(/api/permission) public class PermissionRuleController { private final ApplicationEventPublisher eventPublisher; PostMapping(/reload) public ResultVoid 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() { ListPermissionRule 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 ListOrder queryOrders(Long userId) { // 查询订单逻辑 } }这里permissionValidator是容器里的一个Beancheck方法内部可以去数据库或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; } // 从缓存中获取用户的所有权限码集合 SetString permissions rolePermissionCache.getPermissionsByUsername(auth.getName()); boolean allowed permissions.contains(permissionCode); log.debug(用户 {} 校验权限码 {}结果 {}, auth.getName(), permissionCode, allowed); return allowed; } }5.3 设计完整的分级权限模型把URL动态规则和方法级权限组合起来我最终落地了一个三层模型可以参考一下层级实现方式控制粒度典型场景第一层URL入口自定义AuthorizationManagerURL路径 HTTP方法接口能不能访问登录才行还是某角色才行第二层操作权限PreAuthorize 权限码业务操作订单审核按钮、导出的权限第三层数据权限PostAuthorize / 手动过滤数据行级只能看自己部门的订单URL层优先拦截最粗粒度的请求方法级做第二道关卡。这样管理员在后台既能配菜单权限URL层也能配操作权限方法级权限码覆盖了绝大多数后台系统的权限诉求。5.4 三种方案的选择建议写到这里一并说下三种方案怎么选。如果你要新起一个项目或者旧项目正在大改权限模块我建议直接采用方案一加方案二数据库规则 自定义AuthorizationManager 缓存主动刷新这套最干净适合绝大多数中后台系统。如果你有非常细颗粒度的操作权限需求就在方案一/二的基础上叠加方案三的方法级安全。如果只是给一个小型内部工具加个动态开关不需要复杂角色模型那方案二也可以简化成“配置文件里的规则 手动触发刷新”不必上表结构设计。但不管怎么简化核心的AuthorizationManager思路是不变的你把它吃透了后面换什么存储、刷不刷新都是锦上添花的事。6. 性能优化规则缓存、匹配器复用和失败降级策略动态URL权限最容易招黑的就是“性能差”——每次请求都扫一遍规则表那肯定慢啊。但实际上只要做对缓存和匹配优化性能完全不是问题甚至能比默认的静态配置更快。这章专门讲性能方案。6.1 本地缓存与缓存刷新策略最直接的是把规则全量加载到本地内存查询时只做内存匹配。JVM内存缓存这块我强烈推荐Caffeine性能好、淘汰策略灵活Configuration public class CacheConfig { Bean public CacheString, ListPermissionRule permissionRuleCache() { return Caffeine.newBuilder() .maximumSize(1024) .expireAfterWrite(Duration.ofMinutes(5)) .build(); } }但有个细节需要注意我缓存的是“匹配结果”还是“全量规则”两种思路各有优劣。缓存全量规则内存占用和规则条数成正比每次请求遍历规则匹配时间复杂度O(N)。缓存匹配结果以uri method为key缓存匹配到的规则列表。规则变了清空整个缓存。优势是每次请求直接命中时间复杂度O(1)规则量大了也扛得住。我这边最终采用了“匹配结果缓存 规则变化主动清空”的组合public ListPermissionRule matchRules(String uri, String httpMethod) { String cacheKey httpMethod : uri; ListPermissionRule rules localCache.getIfPresent(cacheKey); if (rules ! null) { return rules; } // 未命中遍历全量规则匹配 ListPermissionRule 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 ListString roleCodes; private int sortOrder; private RequestMatcher requestMatcher; // 构建时初始化 PermissionRule(String urlPattern, String httpMethod, ListString 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 ListPermissionRule loadRulesWithFallback() { ListPermissionRule cached getLocalCache(); if (cached ! null !cached.isEmpty()) { return cached; } try { ListPermissionRule 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, SetString 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;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录或登录过期\}); }) .accessDeniedHandler((request, response, accessDeniedException) - { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(application/json;charsetUTF-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这些敏感信息只打用户名、角色、路径即可。
返回列表