Spring Boot 3 进入 Spring Security 6 时代后,请求级授权的核心模型逐渐从 FilterSecurityInterceptor + AccessDecisionManager + Voter 转向 AuthorizationFilter + AuthorizationManager。对于后台管理系统、SaaS 平台等需要运行时调整权限的应用,可以把 URL、HTTP Method 与权限码的关系存入数据库,由自定义 AuthorizationManager 在请求进入 Controller 前完成动态授权。
本文围绕 Spring Security 6 的动态 URL 权限控制展开,从认证与授权的边界、RBAC 数据模型、AuthorizationManager 原理、动态 URL 匹配、权限缓存、JWT 权限实时生效、401/403 异常处理,到菜单、按钮、接口权限解耦与生产环境实践,整理出一套可以直接落地到 Spring Boot 3 项目的实现思路。
为什么需要动态 URL 权限 一个普通 Spring Security 项目通常可以直接在配置类中声明权限:
1 2 3 4 5 http.authorizeHttpRequests(auth -> auth .requestMatchers("/api/admin/**" ).hasRole("ADMIN" ) .requestMatchers("/api/order/**" ).hasAuthority("order:read" ) .anyRequest().authenticated() );
这种方式很直观,但有一个明显的问题:
URL 与权限的关系被写死在代码中。
假设系统最初规定:
1 GET /api/orders/** -> ROLE_ADMIN
后来产品希望:
1 2 3 管理员可以访问 财务角色也可以访问 运营角色只能访问部分查询接口
如果权限规则全部写在 Java 代码里,就意味着每次调整权限都可能经历:
1 2 3 4 5 6 7 8 9 修改代码 ↓ 重新编译 ↓ 构建镜像 ↓ 部署 ↓ 重启服务
对于企业后台、ERP、CRM、财务系统、SaaS 管理平台来说,这显然不够灵活。
更合理的做法是把:
放到数据库。
例如:
HTTP Method
URL Pattern
Permission
GET
/api/orders
api:order:list
GET
/api/orders/{id}
api:order:detail
POST
/api/orders
api:order:create
PUT
/api/orders/{id}
api:order:update
DELETE
/api/orders/{id}
api:order:delete
管理员只需要调整:
关系,就可以动态改变系统授权。
这种设计的核心思想可以表示为:
flowchart LR
U[User] --> UR[UserRole]
UR --> R[Role]
R --> RP[RolePermission]
RP --> P[Permission]
P --> API[API Permission]
API --> URL[HTTP Method + URL Pattern]
用户并不是直接和 URL 建立关系,而是:
1 2 3 4 5 User -> Role -> Permission -> API Rule -> URL
这正是 RBAC 与动态 URL 授权结合的关键。
Spring Security 6 的授权模型发生了什么变化 很多 Spring Security 老教程仍然基于类似下面的结构:
1 2 3 4 5 6 7 8 9 FilterSecurityInterceptor ↓ SecurityMetadataSource ↓ ConfigAttribute ↓ AccessDecisionManager ↓ AccessDecisionVoter
典型实现会自定义:
1 FilterInvocationSecurityMetadataSource
以及:
一个组件负责:
另一个组件负责:
这种方案曾经非常常见。
Spring Security 新授权体系则围绕:
进行抽象。
使用 authorizeHttpRequests 时,请求级授权由 AuthorizationFilter 调用对应的 AuthorizationManager 完成。相比旧模型,它把 MetadataSource、ConfigAttribute、DecisionManager、Voter 等多层概念压缩成了更加直接的授权决策接口。
可以把新的调用关系理解成:
flowchart LR
A[HTTP Request]
--> B[SecurityFilterChain]
B --> C[Authentication Filters]
C --> D[AuthorizationFilter]
D --> E[AuthorizationManager]
E --> F{授权结果}
F -->|允许| G[DispatcherServlet]
F -->|拒绝| H[AccessDeniedException]
这也是动态 URL 权限控制在 Spring Security 6 中最自然的扩展点。
AuthorizationManager 到底是什么 AuthorizationManager<T> 的职责非常单纯:
判断当前 Authentication 是否有权访问对象 T。
Spring Security 6.5 中接口核心已经演进为:
1 2 3 4 AuthorizationResult authorize ( Supplier<Authentication> authentication, T object ) ;
早期 Spring Security 6 示例大量使用:
1 2 3 4 AuthorizationDecision check ( Supplier<Authentication> authentication, T object ) ;
但从 Spring Security 6.4 开始,check 已被标记为 deprecated,官方建议新的代码实现 authorize。因此如果你正在维护 2023~2024 年的 Spring Security 6 示例,看到 check 并不奇怪,但新代码更适合使用 authorize。
对于 HTTP 请求:
1 T = RequestAuthorizationContext
因此可以实现:
1 AuthorizationManager<RequestAuthorizationContext>
从 RequestAuthorizationContext 中又能够获得:
1 HttpServletRequest request = context.getRequest();
于是动态授权所需要的关键数据就齐了:
1 2 3 4 5 6 7 Authentication ↓ 当前用户有哪些权限 HttpServletRequest ↓ 当前请求 Method + URI
剩下的问题就变成:
1 2 3 4 5 6 7 根据 Method + URI 找权限规则 ↓ 得到 permissionCode ↓ Authentication authorities ↓ 是否包含 permissionCode
一次动态授权请求的完整链路 假设用户请求:
1 DELETE /api/orders/10001
数据库中配置:
1 2 DELETE /api/orders/{id} -> api:order:delete
用户拥有:
1 2 api:order:list api:order:detail
但没有:
整个请求过程可以表示为:
sequenceDiagram
participant C as Client
participant S as SecurityFilterChain
participant J as JwtAuthenticationFilter
participant A as AuthorizationFilter
participant M as DynamicAuthorizationManager
participant R as PermissionRuleCache
participant MVC as DispatcherServlet
C->>S: DELETE /api/orders/10001
S->>J: Authentication
J->>J: 解析 Token
J->>J: 构造 Authentication
J->>A: 继续 FilterChain
A->>M: authorize(authentication, context)
M->>R: match(DELETE, /api/orders/10001)
R-->>M: api:order:delete
M->>M: 检查 authorities
alt 拥有 api:order:delete
M-->>A: granted
A->>MVC: 继续请求
else 没有权限
M-->>A: denied
A-->>C: 403 Forbidden
end
这里要特别分清:
1 2 JWT Filter -> 解决“你是谁” AuthorizationManager -> 解决“你能不能访问”
也就是:
1 Authentication != Authorization
认证和授权揉成一个 Filter,短期看代码似乎少了,长期通常会变成权限系统里的“祖传巨型 if”。
推荐的 RBAC 权限模型 对于真正的后台管理系统,不建议简单做:
更推荐:
1 User -> Role -> Permission -> API
原因是角色应该描述:
而权限应该描述:
例如:
1 2 3 ROLE_FINANCE ROLE_ADMIN ROLE_OPERATOR
属于角色。
而:
1 2 3 4 api:order:list api:order:create api:order:delete api:finance:settlement:approve
属于权限。
这样角色和业务能力才能解耦。
数据库表设计 一个最小可用模型至少需要:
1 2 3 4 5 6 7 8 sys_user sys_role sys_user_role sys_permission sys_role_permission sys_api
关系如下:
erDiagram
SYS_USER ||--o{ SYS_USER_ROLE : has
SYS_ROLE ||--o{ SYS_USER_ROLE : assigned
SYS_ROLE ||--o{ SYS_ROLE_PERMISSION : grants
SYS_PERMISSION ||--o{ SYS_ROLE_PERMISSION : contains
SYS_PERMISSION ||--|| SYS_API : describes
权限表 1 2 3 4 5 6 7 8 9 10 11 12 13 CREATE TABLE sys_permission ( id BIGINT PRIMARY KEY , perm_code VARCHAR (128 ) NOT NULL , perm_name VARCHAR (128 ) NOT NULL , perm_type VARCHAR (20 ) NOT NULL , status VARCHAR (20 ) NOT NULL DEFAULT 'ENABLED' , deleted TINYINT NOT NULL DEFAULT 0 , UNIQUE KEY uk_permission_code ( perm_code, deleted ) );
权限码建议保持稳定。
例如:
1 2 3 4 5 api:order:list api:order:detail api:order:create api:order:update api:order:delete
不要把权限设计成:
1 2 permission_001 permission_002
否则几个月之后数据库就会变成权限考古现场。
API 权限表 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 CREATE TABLE sys_api ( permission_id BIGINT NOT NULL , http_method VARCHAR (16 ) NOT NULL , url_pattern VARCHAR (512 ) NOT NULL , matcher_type VARCHAR (20 ) NOT NULL DEFAULT 'MVC' , public_flag TINYINT NOT NULL DEFAULT 0 , priority INT NOT NULL DEFAULT 0 , PRIMARY KEY (permission_id), UNIQUE KEY uk_api_rule ( http_method, url_pattern, matcher_type ) );
例如:
1 2 3 4 5 permission_id = 10001 permissionCode = api:order:detail GET /api/orders/{id}
最终表达的就是:
1 2 GET /api/orders/{id} 需要 api:order:detail
为什么 HTTP Method 必须参与权限匹配 只存 URL 是一个非常常见的坑。
例如:
同时可能存在:
1 2 3 GET /api/users/{id} PUT /api/users/{id} DELETE /api/users/{id}
显然它们的风险完全不同。
合理权限应该是:
1 2 3 4 5 6 7 8 GET /api/users/{id} -> api:user:detail PUT /api/users/{id} -> api:user:update DELETE /api/users/{id} -> api:user:delete
如果只按照:
授权,就可能出现:
这种权限系统确实很动态——攻击者看到都会觉得它相当有活力。
因此动态 URL 权限规则至少应该由:
1 2 3 HTTP Method + URL Pattern
共同组成。
PermissionRule 数据结构 可以先定义一个授权规则:
1 2 3 4 5 6 7 8 public record ApiPermissionRule ( String httpMethod, String urlPattern, String permissionCode, boolean publicFlag, int priority ) { }
运行时再把数据库规则编译成路径匹配器。
例如:
1 2 3 4 5 6 7 8 public record CompiledPermissionRule ( String httpMethod, String permissionCode, boolean publicFlag, int priority, PathPattern pattern ) { }
这样请求阶段不需要反复解析 URL Pattern。
不要每个请求查询数据库 最简单的实现是:
1 permissionRepository.findByMethodAndUrl(...);
然后每个 HTTP 请求执行一次 SQL。
功能当然能跑。
性能也当然会跑——跑去数据库。
如果系统 QPS 为:
就意味着仅仅权限判断理论上就可能增加:
这完全没有必要。
推荐结构:
flowchart LR
DB[(Database)]
--> LOAD[规则加载]
LOAD --> CACHE[PermissionRuleCache]
REQ[Request]
--> AM[AuthorizationManager]
AM --> CACHE
CHANGE[权限变更]
--> EVENT[PermissionChangedEvent]
EVENT --> CACHE
也就是:
数据库负责保存权限规则,缓存负责处理请求。
这也是动态权限系统非常重要的工程边界。用户自己的 RBAC 设计资料中同样采用“API 规则缓存 + 用户有效权限缓存 + 权限变更刷新”的方案,并强调未登记业务接口默认拒绝。
实现 PermissionRuleCache 下面实现一个简单版本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 @Component public class PermissionRuleCache { private final ApiPermissionRepository repository; private final PathPatternParser parser = PathPatternParser.defaultInstance; private volatile List<CompiledPermissionRule> rules = List.of(); public PermissionRuleCache ( ApiPermissionRepository repository) { this .repository = repository; } @PostConstruct public void init () { refresh(); } public synchronized void refresh () { List<ApiPermissionRule> databaseRules = repository.findEnabledRules(); this .rules = databaseRules.stream() .map(this ::compile) .sorted( Comparator .comparingInt( CompiledPermissionRule::priority) .reversed() .thenComparing( rule -> rule.pattern() .getPatternString() .length(), Comparator.reverseOrder() ) ) .toList(); } private CompiledPermissionRule compile ( ApiPermissionRule rule) { PathPattern pattern = parser.parse(rule.urlPattern()); return new CompiledPermissionRule ( rule.httpMethod(), rule.permissionCode(), rule.publicFlag(), rule.priority(), pattern ); } public Optional<CompiledPermissionRule> match ( HttpServletRequest request) { String requestMethod = request.getMethod(); String requestUri = request.getRequestURI(); String contextPath = request.getContextPath(); String path = contextPath.isEmpty() ? requestUri : requestUri.substring(contextPath.length()); PathContainer pathContainer = PathContainer.parsePath(path); return rules.stream() .filter(rule -> methodMatches( rule.httpMethod(), requestMethod ) ) .filter(rule -> rule.pattern().matches(pathContainer) ) .findFirst(); } private boolean methodMatches ( String configured, String actual) { return "ANY" .equalsIgnoreCase(configured) || configured.equalsIgnoreCase(actual); } }
这里有两个很重要的设计:
1 2 3 4 5 6 7 启动 / 刷新阶段 ↓ 解析 URL Pattern 请求阶段 ↓ 直接执行已经编译好的 matcher
而不是:
1 2 3 4 5 6 7 每次请求 ↓ 查询数据库 ↓ 解析 Pattern ↓ 权限判断
PathPattern 与 AntPath 的版本问题 很多老的动态权限实现会看到:
或者:
Spring Security 6.5 已将 AntPathRequestMatcher 标记为 forRemoval=true,并明确建议迁移到 PathPatternRequestMatcher。PathPatternRequestMatcher 从 Spring Security 6.5 开始提供。
因此如果项目面向较新的 Spring Security 6.x,可以优先考虑 Spring 的 PathPattern 体系。
例如数据库规则:
相比:
也更接近 Spring MVC Controller 本身的表达方式。
实现 DynamicAuthorizationManager 真正的动态授权核心可以非常短。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 @Component public class DynamicAuthorizationManager implements AuthorizationManager <RequestAuthorizationContext> { private final PermissionRuleCache ruleCache; public DynamicAuthorizationManager ( PermissionRuleCache ruleCache) { this .ruleCache = ruleCache; } @Override public AuthorizationResult authorize ( Supplier<Authentication> authenticationSupplier, RequestAuthorizationContext context) { HttpServletRequest request = context.getRequest(); Optional<CompiledPermissionRule> optional = ruleCache.match(request); if (optional.isEmpty()) { return new AuthorizationDecision (false ); } CompiledPermissionRule rule = optional.get(); if (rule.publicFlag()) { return new AuthorizationDecision (true ); } Authentication authentication; try { authentication = authenticationSupplier.get(); } catch (Exception ex) { return new AuthorizationDecision (false ); } if (authentication == null || !authentication.isAuthenticated()) { return new AuthorizationDecision (false ); } boolean granted = authentication.getAuthorities() .stream() .map(GrantedAuthority::getAuthority) .anyMatch(rule.permissionCode()::equals); return new AuthorizationDecision (granted); } }
核心逻辑其实只有四步:
1 2 3 4 1. 当前请求匹配哪条 API 规则? 2. 当前 API 需要哪个 permissionCode? 3. 当前 Authentication 有哪些 authorities? 4. 是否包含 permissionCode?
可以概括为:
1 2 3 4 allowed = authentication.authorities contains request.requiredPermission;
真正困难的地方往往不在这十几行判断,而在:
1 2 3 4 5 6 7 权限模型 URL Pattern 设计 缓存失效 JWT 权限同步 前后端权限边界 默认策略 审计
配置 SecurityFilterChain Spring Boot 3 / Spring Security 6 已经采用组件式 SecurityFilterChain 配置,不再使用过去常见的 WebSecurityConfigurerAdapter。
典型配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Bean SecurityFilterChain securityFilterChain ( HttpSecurity http, DynamicAuthorizationManager authorizationManager, JwtAuthenticationFilter jwtFilter, RestAuthenticationEntryPoint authenticationEntryPoint, RestAccessDeniedHandler accessDeniedHandler) throws Exception { http .csrf(csrf -> csrf.disable()) .cors(Customizer.withDefaults()) .sessionManagement(session -> session.sessionCreationPolicy( SessionCreationPolicy.STATELESS ) ) .exceptionHandling(exception -> exception .authenticationEntryPoint( authenticationEntryPoint ) .accessDeniedHandler( accessDeniedHandler ) ) .authorizeHttpRequests(auth -> auth .dispatcherTypeMatchers( DispatcherType.FORWARD, DispatcherType.ERROR ).permitAll() .requestMatchers( "/api/auth/login" , "/api/auth/refresh" , "/actuator/health" ).permitAll() .anyRequest() .access(authorizationManager) ) .addFilterBefore( jwtFilter, UsernamePasswordAuthenticationFilter.class ); return http.build(); } }
Spring Security 的 AuthorizationFilter 不仅可能处理普通 REQUEST,其他 dispatch 类型也可能重新进入授权流程,因此在传统 MVC 应用中,需要结合实际情况考虑 ERROR、FORWARD 等 DispatcherType。官方文档也专门指出了这一行为。
白名单应该写在哪里 通常有两种方式。
第一种:
1 2 3 4 .requestMatchers( "/api/auth/login" , "/api/auth/refresh" ).permitAll()
第二种则是全部放入动态权限表:
1 2 POST /api/auth/login public_flag = true
工程上更推荐:
1 2 3 4 5 6 7 框架基础白名单 ↓ SecurityConfig 业务公开 API ↓ 权限数据库
例如:
属于基础设施接口,可以直接配置。
而:
1 2 GET /api/products/public/** GET /api/articles/**
等业务 API,如果确实需要后台动态控制公开状态,可以放入数据库。
不要让后台管理员随手把:
配置成:
否则权限中心就从“安全管理平台”变成了“一键卸门”。
默认允许还是默认拒绝 这是动态 URL 权限系统里最关键的设计之一。
假设开发人员新增:
1 2 3 @PostMapping("/api/admin/users/import") public void importUsers () { }
但是忘记向权限数据库登记。
此时有两种策略。
默认允许
优点是:
缺点是:
默认拒绝
这样新接口必须经过:
1 2 3 4 5 6 7 8 9 开发 ↓ 扫描 ↓ 登记 ↓ 绑定权限 ↓ 启用
才能访问。
对于后台管理、财务、订单、权限中心等业务,更推荐:
Fail Closed:没有明确允许,就拒绝。
也就是代码中的:
1 2 3 if (optional.isEmpty()) { return new AuthorizationDecision (false ); }
URL 匹配冲突怎么处理 动态规则一定会遇到这种情况:
1 2 3 GET /api/orders/** GET /api/orders/{id} GET /api/orders/export
请求:
究竟应该匹配哪一个?
因此权限规则必须有确定的优先级。
推荐:
1 2 3 4 5 6 7 8 9 10 11 12 13 HTTP Method 精确匹配 > ANY EXACT > MVC / PathPattern > 宽泛通配符 路径越具体 > 路径越宽泛
还可以增加:
显式控制。
例如:
priority
method
pattern
100
GET
/api/orders/export
80
GET
/api/orders/{id}
10
GET
/api/orders/**
授权规则绝不能依赖:
否则同一份权限数据换个执行计划,安全策略都可能跟着“量子叠加”。
Authentication 中应该存 Role 还是 Permission 可以只放角色:
然后每次授权:
1 2 3 4 URL -> Permission -> Role -> Authentication Role
但这会增加运行时计算。
另一种方式是登录时直接展开有效权限:
1 2 3 4 5 6 ROLE_ADMIN api:user:list api:user:create api:order:list api:order:approve
最终生成:
1 Collection<GrantedAuthority>
例如:
1 2 3 4 List<GrantedAuthority> authorities = permissionCodes.stream() .map(SimpleGrantedAuthority::new ) .toList();
这样动态 AuthorizationManager 只需要:
1 authentication.getAuthorities()
即可完成判断。
对于中后台 RBAC 系统,这种方式通常更加清晰:
1 2 3 4 5 6 7 Role ↓ 负责权限组合 Permission ↓ 负责真正授权判断
ROLE_ 前缀为什么总是容易出问题 Spring Security 中:
与:
1 hasAuthority("ROLE_ADMIN" )
通常表达相同的意思。
hasRole 会按照默认角色前缀处理 ROLE_。
例如:
最终通常生成:
而:
1 new SimpleGrantedAuthority ("ADMIN" )
得到的就只是:
如果这时授权判断使用:
就会去匹配:
自然无法匹配:
提供的 Spring Security 课程资料也专门通过源码说明了 roles("ADMIN") 与直接构造 SimpleGrantedAuthority("ADMIN") 的差异,以及 ROLE_ 前缀造成 403 的典型问题。
因此建议项目统一规范:
1 2 3 4 Role: ROLE_ADMIN ROLE_USER ROLE_FINANCE
业务权限则:
1 2 3 api:user:list api:user:create api:finance:settlement:approve
授权业务 API 时尽量使用:
而不是把所有操作权限都设计成 Role。
JWT 场景下动态权限最大的坑 假设登录成功以后,把所有权限都放进 JWT:
1 2 3 4 5 6 7 8 9 { "sub" : "10001" , "authorities" : [ "api:order:list" , "api:order:create" , "api:order:delete" ] , "exp" : 1780000000 }
现在管理员撤销:
但旧 Token 里面依然存在:
只要 Token 没过期,用户就仍然可能继续删除订单。
这就出现了一个很尴尬的情况:
1 2 数据库权限已经动态了 Token 权限却是静态的
最终整个“动态权限”只动态了一半。
推荐的 JWT 权限设计 更合理的 JWT 可以只保存身份信息:
1 2 3 4 5 6 7 { "sub" : "10001" , "jti" : "01JXYZ..." , "tokenVersion" : 12 , "iat" : 1780000000 , "exp" : 1780003600 }
然后请求时:
1 2 3 4 5 6 7 JWT ↓ userId ↓ 权限缓存 ↓ authorities
例如:
1 rbac:user:10001:authorities
缓存内容:
1 2 api:order:list api:order:create
当管理员调整角色权限时:
1 2 3 4 5 6 7 数据库事务提交 ↓ PermissionChangedEvent ↓ 删除相关用户权限缓存 ↓ 下一次请求重新加载
这样数据库权限变化可以较快生效。
相关 RBAC 设计资料同样建议 JWT 中只保存身份、Token 版本等稳定信息,而不要将完整动态权限永久固化进 Token;权限变化后应通过版本号或缓存失效使新的授权结果立即生效。
两级缓存设计 生产环境推荐至少考虑两类缓存。
API 规则缓存 保存:
1 2 3 Method + URL Pattern ↓ Permission
例如:
1 2 GET /api/orders/{id} -> api:order:detail
缓存可以表示为:
用户权限缓存 保存:
1 2 3 User ↓ Effective Authorities
例如:
1 rbac:user:10001:authorities
内容:
1 2 3 4 5 ROLE_FINANCE api:order:list api:order:detail api:settlement:list api:settlement:approve
一次请求于是变成:
flowchart LR
R[Request]
--> JWT[JWT Authentication]
JWT --> UC[User Authority Cache]
R --> RC[API Rule Cache]
UC --> AM[AuthorizationManager]
RC --> AM
AM --> RESULT{Granted?}
RESULT -->|Yes| MVC[Controller]
RESULT -->|No| DENY[403]
正常请求完全不需要访问数据库。
多实例部署时权限缓存怎么刷新 单实例里:
就够了。
但 Kubernetes 或多机部署中:
1 2 3 Instance A Instance B Instance C
管理员在 A 修改权限。
如果只刷新 A:
1 2 3 A -> 新规则 B -> 老规则 C -> 老规则
系统就会出现:
1 2 3 同一个用户刷新几次页面 一会儿 200 一会儿 403
因为负载均衡把请求发到了不同实例。
生产环境需要广播权限变化。
例如:
flowchart LR
ADMIN[Permission Change]
--> DB[(Database)]
DB --> EVENT[Permission Changed]
EVENT --> REDIS[Redis Pub/Sub]
REDIS --> A[Instance A]
REDIS --> B[Instance B]
REDIS --> C[Instance C]
A --> CA[Refresh Cache]
B --> CB[Refresh Cache]
C --> CC[Refresh Cache]
也可以使用:
1 2 3 4 5 Redis Pub/Sub Kafka RocketMQ Nacos Config 数据库版本号轮询
小型项目直接刷新全量规则通常已经足够。
权限变更事件 可以定义:
1 2 3 4 5 6 public record PermissionChangedEvent ( Long roleId, Long userId, boolean apiRuleChanged ) { }
权限服务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Transactional public void updateRolePermissions ( Long roleId, Set<Long> permissionIds) { rolePermissionRepository .replacePermissions( roleId, permissionIds ); applicationEventPublisher.publishEvent( new PermissionChangedEvent ( roleId, null , true ) ); }
监听:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 @Component public class PermissionChangedListener { private final PermissionRuleCache ruleCache; private final UserAuthorityCache userAuthorityCache; public PermissionChangedListener ( PermissionRuleCache ruleCache, UserAuthorityCache userAuthorityCache) { this .ruleCache = ruleCache; this .userAuthorityCache = userAuthorityCache; } @TransactionalEventListener( phase = TransactionPhase.AFTER_COMMIT ) public void handle ( PermissionChangedEvent event) { if (event.apiRuleChanged()) { ruleCache.refresh(); } userAuthorityCache.evictByRole( event.roleId() ); } }
这里使用:
非常重要。
否则可能发生:
1 2 3 4 5 缓存先刷新 ↓ 数据库事务回滚 ↓ 缓存和数据库状态不一致
401 与 403 必须区分 前后端分离项目中经常统一返回:
1 2 3 4 { "code" : "AUTH_FORBIDDEN" , "message" : "无权限访问" }
但 HTTP 状态仍应该区分。
401 Unauthorized 表示:
例如:
1 2 3 没有 Token Token 无效 Token 已过期
403 Forbidden 表示:
1 2 Spring Security 已经知道你是谁 但你没有这个权限
例如:
1 2 当前用户有 api:order:list 请求却需要 api:order:delete
Spring Security 的异常翻译机制会根据认证状态,通过 AuthenticationEntryPoint 或 AccessDeniedHandler 将安全异常转换为对应 HTTP 响应。
自定义 401 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 @Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { private final ObjectMapper objectMapper; public RestAuthenticationEntryPoint ( ObjectMapper objectMapper) { this .objectMapper = objectMapper; } @Override public void commence ( HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus( HttpServletResponse.SC_UNAUTHORIZED ); response.setContentType( MediaType.APPLICATION_JSON_VALUE ); response.setCharacterEncoding( StandardCharsets.UTF_8.name() ); objectMapper.writeValue( response.getOutputStream(), Map.of( "code" , "AUTH_UNAUTHORIZED" , "message" , "请先登录或重新认证" ) ); } }
自定义 403 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 @Component public class RestAccessDeniedHandler implements AccessDeniedHandler { private final ObjectMapper objectMapper; public RestAccessDeniedHandler ( ObjectMapper objectMapper) { this .objectMapper = objectMapper; } @Override public void handle ( HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { response.setStatus( HttpServletResponse.SC_FORBIDDEN ); response.setContentType( MediaType.APPLICATION_JSON_VALUE ); response.setCharacterEncoding( StandardCharsets.UTF_8.name() ); objectMapper.writeValue( response.getOutputStream(), Map.of( "code" , "AUTH_FORBIDDEN" , "message" , "没有访问该资源的权限" ) ); } }
最初提供的 Spring Boot 3 / Spring Security 6 动态权限文章同样通过 AuthenticationEntryPoint 和 AccessDeniedHandler 分别处理未认证和未授权情况,并基于新的 Security 配置方式完成动态权限接入。
为什么 ControllerAdvice 经常接不到 Security 异常 典型请求链:
1 2 3 4 5 6 7 Servlet Filter ↓ Spring Security ↓ DispatcherServlet ↓ Controller
@RestControllerAdvice 属于:
内部的 Spring MVC 异常处理体系。
而认证、授权失败通常发生在:
1 Spring Security Filter Chain
此时请求甚至还没有进入 Controller。
因此不要期待:
1 @ExceptionHandler(AccessDeniedException.class)
一定能统一接住 Security Filter 阶段的所有异常。
认证和请求级授权应该优先使用:
1 2 AuthenticationEntryPoint AccessDeniedHandler
这也是 Spring Security 自己提供的扩展点。
菜单权限、按钮权限和接口权限不要混为一谈 一个成熟后台系统通常同时存在:
看上去都是“权限”,但它们解决的问题完全不同。
决定:
例如:
决定:
例如:
1 2 btn:system:user:add btn:system:user:delete
API 真正保护服务端资源:
1 2 api:user:create api:user:delete
合理模型:
flowchart LR
ROLE[Role]
--> MENU[Menu Permission]
ROLE --> BTN[Button Permission]
ROLE --> API[API Permission]
BTN -.调用.-> API
不要做成:
前端按钮隐藏只能改善用户体验。
攻击者完全可以绕开前端直接:
1 2 curl -X DELETE \ https://example.com/api/users/100
因此:
后端 API 权限才是真正的安全边界。
用户已有的 RBAC 设计资料也特别强调了这一点:菜单决定页面入口,按钮决定前端操作入口,而后端强授权应始终以独立 API Permission 为依据;按钮和 API 最好采用多对多关系,而不是直接绑定成同一个权限对象。
一个按钮为什么可能对应多个 API 例如页面上的:
用户点击以后前端可能执行:
1 2 3 4 5 GET /api/users/deletable ↓ DELETE /api/users/batch ↓ GET /api/users
因此:
可能对应:
1 2 3 api:user:list-deletable api:user:batch-delete api:user:list
反过来:
三个按钮也可能最终调用同一个:
所以关系天然可能是:
而不是:
方法级权限作为第二道防线 请求级权限解决:
但一些关键业务操作还适合增加方法级权限。
例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Service public class RoleService { @PreAuthorize( "hasAuthority('api:rbac:role:permission:update')" ) @Transactional public void replacePermissions ( Long roleId, Set<Long> permissionIds) { } }
启用:
Spring Security 6 的方法安全支持 @PreAuthorize、@PostAuthorize、@PreFilter 和 @PostFilter 等机制,而 Spring Boot Starter Security 本身不会自动打开方法级授权。
可以把两层防线理解成:
1 2 3 4 5 6 7 AuthorizationManager ↓ HTTP 边界 @PreAuthorize ↓ 业务方法边界
对于:
1 2 3 4 5 删除角色 变更角色权限 重置密码 资金审批 结算确认
这样的高风险操作,双层授权很有价值。
CSRF 不要看到前后端分离就机械关闭 很多示例都有:
1 .csrf(csrf -> csrf.disable())
如果系统是:
1 2 3 4 5 REST API + Authorization: Bearer <token> + STATELESS
关闭 CSRF 通常是合理的。
但如果认证依赖:
1 2 3 Cookie Session 浏览器自动携带凭证
那么不能看到教程里有:
就直接复制。
判断依据应该是认证机制,而不是:
Vue 对此表示自己很无辜。
OPTIONS 预检请求怎么处理 前后端跨域时,浏览器可能先发送:
很多旧示例直接:
1 2 .requestMatchers(HttpMethod.OPTIONS, "/**" ) .permitAll()
可以工作,但更完整的做法是正确配置:
1 http.cors(Customizer.withDefaults());
并提供受控的:
尤其不要生产环境组合:
1 2 3 Access-Control-Allow-Origin: * + Allow-Credentials: true
跨域策略应该与权限策略一起设计,而不是为了“让请求先通再说”把整个 CORS 配置点成无敌模式。
Spring Security 6 中 permitAll 比 ignoring 更合适 静态资源或公开资源过去经常配置:
现代 Spring Security 更倾向于让请求继续经过安全 Filter Chain,然后:
这样即使无需认证,安全 Header 等 Security 功能依然可以正常工作。
而 Spring Security 6 对 Authentication 的读取采用延迟策略,对于 permitAll、denyAll 这类无需身份信息的规则,可以避免不必要地读取认证信息。官方因此也推荐静态资源优先使用 permitAll 而不是直接绕过 Security。
API 权限自动扫描 动态权限系统还有一个长期维护问题:
1 2 Controller 越来越多 权限表怎么保证不漏?
一个很实用的方案是:
1 扫描 Spring MVC HandlerMapping
获取:
1 2 3 4 Controller HandlerMethod HTTP Method URL Pattern
自动生成权限草稿。
流程:
flowchart TD
START[应用启动 / 手工执行扫描]
--> H[RequestMappingHandlerMapping]
H --> C[读取 Controller Mapping]
C --> M[HTTP Method]
C --> U[URL Pattern]
C --> META[@Operation / 自定义权限注解]
M --> COMPARE[与 sys_api 比较]
U --> COMPARE
META --> COMPARE
COMPARE --> NEW[新增接口]
COMPARE --> EXIST[已有接口]
COMPARE --> OLD[已经消失接口]
NEW --> DRAFT[生成 DRAFT]
OLD --> ORPHAN[标记 ORPHANED]
关键原则是:
扫描发现接口,不等于自动放行接口。
推荐:
1 2 3 4 5 6 7 8 9 扫描 ↓ DRAFT ↓ 管理员确认权限码 ↓ 绑定角色 ↓ ENABLED
而不是:
1 2 3 扫描到新 Controller ↓ 自动 permit
否则一个测试接口:
1 @GetMapping("/debug/all-users")
刚提交代码就能自动解锁,安全团队大概会解锁另一种情绪。
权限注解可以只描述元数据 例如:
1 2 3 4 5 6 7 8 9 10 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface ApiPermission { String code () ; String name () ; RiskLevel risk () default RiskLevel.NORMAL; }
Controller:
1 2 3 4 5 6 7 8 9 10 11 @PostMapping("/api/orders") @ApiPermission( code = "api:order:create", name = "创建订单", risk = RiskLevel.HIGH ) public OrderResponse create ( @RequestBody CreateOrderRequest request) { return orderService.create(request); }
扫描任务可以读取:
1 2 3 HTTP Method = POST URL = /api/orders Permission = api:order:create
这样:
1 2 代码描述“这个接口是什么” 数据库决定“谁能访问这个接口”
二者职责非常清晰。
权限规则变更必须审计 以下操作建议全部记录:
1 2 3 4 5 6 7 角色新增权限 角色删除权限 用户角色变更 API 权限启用/禁用 URL Pattern 修改 public_flag 修改 角色继承关系修改
审计表至少记录:
1 2 3 4 5 6 7 8 9 10 11 CREATE TABLE sys_audit_log ( id BIGINT PRIMARY KEY , actor_user_id BIGINT , action VARCHAR (128 ) NOT NULL , target_type VARCHAR (64 ), target_id BIGINT , before_json JSON, after_json JSON, ip VARCHAR (64 ), created_at DATETIME(6 ) NOT NULL );
例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 { "action" : "RBAC_ROLE_PERMISSION_UPDATE" , "actorUserId" : 10001 , "targetType" : "ROLE" , "targetId" : 20001 , "before" : [ "api:order:list" ] , "after" : [ "api:order:list" , "api:order:delete" ] }
权限问题一旦发生,最重要的问题通常不是:
而是:
权限系统建议采用 Fail Closed 可以把安全策略压缩成一句话:
包括:
1 2 3 4 5 6 未登记 API -> 拒绝 禁用 API -> 拒绝 未知 Matcher -> 拒绝 权限缓存加载失败 -> 拒绝 角色已禁用 -> 拒绝 用户已禁用 -> 拒绝
不要为了“系统不能因为权限服务异常而影响业务”写成:
1 2 3 catch (Exception e) { return new AuthorizationDecision (true ); }
权限服务异常时自动放行,相当于门禁系统断电后把所有门打开。
可用性确实提高了。
只不过提高的是未授权访问的可用性。
Spring Boot 2 / Security 5 到 Spring Boot 3 / Security 6 的常见迁移变化 老项目迁移时,可以重点关注下面这些变化。
旧写法
Spring Security 6 方向
WebSecurityConfigurerAdapter
SecurityFilterChain Bean
authorizeRequests()
authorizeHttpRequests()
antMatchers()
requestMatchers()
FilterSecurityInterceptor 授权体系
AuthorizationFilter + AuthorizationManager
AccessDecisionManager
AuthorizationManager
AccessDecisionVoter
通常直接封装到 AuthorizationManager
javax.servlet.*
jakarta.servlet.*
@EnableGlobalMethodSecurity
@EnableMethodSecurity
官方文档明确指出,当使用 authorizeHttpRequests 时,Spring Security 使用 AuthorizationFilter,并采用简化后的 AuthorizationManager API;这也是从旧授权体系迁移时最值得建立的新认知。
此外,如果项目升级到较新的 Spring Security 6:
1 AuthorizationManager#check
已经逐渐迁移为:
1 AuthorizationManager#authorize
而:
也已经进入废弃迁移阶段。
推荐的最终架构 综合前面的设计,一个比较完整的权限体系可以组织成:
flowchart TD
CLIENT[Client]
--> FILTER[JwtAuthenticationFilter]
FILTER --> AUTH[Authentication]
AUTH --> AF[AuthorizationFilter]
AF --> DAM[DynamicAuthorizationManager]
DAM --> RULE[PermissionRuleCache]
RULE --> API[(sys_api)]
API --> PERM[(sys_permission)]
DAM --> USERAUTH[Authentication Authorities]
USERAUTH --> USERCACHE[User Authority Cache]
USERCACHE --> RBAC[RBAC Service]
RBAC --> USER[(sys_user)]
RBAC --> ROLE[(sys_role)]
RBAC --> RP[(sys_role_permission)]
DAM --> DECISION{Granted}
DECISION -->|Yes| MVC[Controller]
DECISION -->|No| HANDLER[401 / 403 Handler]
MVC --> SERVICE[Service]
SERVICE --> METHOD[@PreAuthorize]
METHOD --> REPO[Repository]
组件职责可以明确拆成:
组件
责任
JwtAuthenticationFilter
确定当前用户是谁
UserAuthorityCache
保存用户有效权限
PermissionRuleCache
保存 URL → Permission 规则
DynamicAuthorizationManager
完成当前请求授权
AuthenticationEntryPoint
处理未认证
AccessDeniedHandler
处理已认证但无权限
@PreAuthorize
保护关键业务方法
RbacService
计算角色与有效权限
PermissionChangedEvent
驱动缓存失效
AuditService
保存授权变更和拒绝记录
权限系统做到这里,职责基本就清楚了。
测试动态权限不能只测 200 Security 集成测试至少应该覆盖:
场景
预期
无 Token
401
Token 无效
401
Token 过期
401
已登录但无权限
403
拥有权限
200
API 未登记
403
API 被禁用
403
public API
200
HTTP Method 不匹配
403
权限被撤销
下一次请求生效
URL Pattern 冲突
命中确定规则
用户被禁用
401/403,按认证模型统一
角色被禁用
权限立即失效
例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 @SpringBootTest @AutoConfigureMockMvc class OrderSecurityTests { @Autowired MockMvc mvc; @Test @WithMockUser( authorities = "api:order:list" ) void listOrdersShouldPass () throws Exception { mvc.perform( get("/api/orders" ) ) .andExpect( status().isOk() ); } @Test @WithMockUser( authorities = "api:order:list" ) void deleteOrderShouldBeForbidden () throws Exception { mvc.perform( delete("/api/orders/10001" ) ) .andExpect( status().isForbidden() ); } }
Spring Security 官方也提供了基于 MockMvc 和 Mock Authentication 的请求级授权测试支持。
上线前检查清单 动态 URL 权限真正上线之前,可以重点检查:
URL 权限是否同时匹配 HTTP Method;
未登记业务接口是否默认拒绝;
权限规则是否缓存,而不是每次查询数据库;
权限修改后缓存是否能够失效;
多实例部署是否有缓存广播机制;
JWT 是否固化了长期权限;
401 与 403 是否正确区分;
前端按钮隐藏是否没有被误认为后端授权;
菜单、按钮和 API 权限是否已经解耦;
Role 与 Authority 命名是否统一;
是否存在 ROLE_ 前缀混乱;
URL Pattern 冲突是否具有确定优先级;
Swagger、Actuator 等运维端点是否受到保护;
CORS 是否使用明确的 Origin 白名单;
权限变更是否有审计记录;
敏感 Service 是否增加方法级权限;
新 Controller 是否有权限扫描或检查机制;
权限服务异常时是否坚持 Fail Closed。
总结 Spring Security 6 的动态 URL 权限控制,真正的核心并不是再造一套 Security Filter,而是利用新的 AuthorizationManager<RequestAuthorizationContext> 接管请求授权决策。
整个模型可以压缩成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 HTTP Request ↓ Authentication ↓ Method + URL ↓ PermissionRuleCache ↓ Permission Code ↓ Authentication.authorities ↓ AuthorizationManager ↓ Granted / Denied
在此基础上,再加入:
1 2 3 4 5 6 7 User ↓ Role ↓ Permission ↓ API
就形成了标准的动态 RBAC 请求授权。
生产系统还需要进一步处理:
1 2 3 4 5 6 7 8 9 API 规则缓存 用户权限缓存 JWT 权限实时失效 多实例缓存广播 菜单 / 按钮 / API 解耦 权限扫描 审计 方法级二次授权 默认拒绝
真正稳定的权限系统不应该让业务代码到处充斥:
也不应该让 Controller 到处硬编码:
更合理的边界是:
1 2 3 4 身份认证负责“你是谁” RBAC 负责“你拥有什么能力” API Permission 负责“这个操作需要什么能力” AuthorizationManager 负责“这次请求能不能通过”
这样一来,权限配置可以动态调整,而 Spring Security 仍然负责真正的后端安全边界;角色、菜单、按钮、URL 和业务代码之间也不会互相绑死。
参考资料与版本说明 Spring Security 官方请求级授权文档说明了 AuthorizationFilter、AuthorizationManager、authorizeHttpRequests、permitAll、DispatcherType 授权以及自定义外部授权服务等机制。
Spring Security 6.5 API 文档显示,AuthorizationManager#authorize 已成为新的授权入口,而旧的 check 已进入 deprecated 状态。
对于路径匹配,Spring Security 6.5 新增 PathPatternRequestMatcher,同时将 AntPathRequestMatcher 标记为待移除。
Spring Security 方法级安全可以通过 @EnableMethodSecurity 开启,并支持 @PreAuthorize 等方法授权机制。
本文同时结合了提供的 Spring Security 资料中关于 Security Filter、PasswordEncoder 与 ROLE_ 前缀问题的分析,以及已有 RBAC1 设计中关于 Spring Security 6 动态授权、API 权限缓存、JWT 权限失效、菜单/按钮/API 解耦、默认拒绝和审计策略的工程实践。