Spring Security 6 动态 URL 权限控制:基于 AuthorizationManager 的 RBAC 实现

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 管理平台来说,这显然不够灵活。

更合理的做法是把:

1
请求资源 -> 所需权限

放到数据库。

例如:

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

管理员只需要调整:

1
Role -> Permission

关系,就可以动态改变系统授权。

这种设计的核心思想可以表示为:

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

以及:

1
AccessDecisionManager

一个组件负责:

1
当前 URL 需要什么权限?

另一个组件负责:

1
当前用户是否拥有这些权限?

这种方案曾经非常常见。

Spring Security 新授权体系则围绕:

1
AuthorizationManager<T>

进行抽象。

使用 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

但没有:

1
api:order:delete

整个请求过程可以表示为:

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
Role -> URL

更推荐:

1
User -> Role -> Permission -> API

原因是角色应该描述:

1
用户是什么身份

而权限应该描述:

1
用户能执行什么动作

例如:

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
/api/users/{id}

同时可能存在:

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

如果只按照:

1
/api/users/{id}

授权,就可能出现:

1
2
3
能查看用户

顺便也能删除用户

这种权限系统确实很动态——攻击者看到都会觉得它相当有活力。

因此动态 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 为:

1
5000

就意味着仅仅权限判断理论上就可能增加:

1
5000 次数据库查询 / 秒

这完全没有必要。

推荐结构:

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 的版本问题

很多老的动态权限实现会看到:

1
AntPathMatcher

或者:

1
AntPathRequestMatcher

Spring Security 6.5 已将 AntPathRequestMatcher 标记为 forRemoval=true,并明确建议迁移到 PathPatternRequestMatcherPathPatternRequestMatcher 从 Spring Security 6.5 开始提供。

因此如果项目面向较新的 Spring Security 6.x,可以优先考虑 Spring 的 PathPattern 体系。

例如数据库规则:

1
/api/orders/{id}

相比:

1
/api/orders/*

也更接近 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 应用中,需要结合实际情况考虑 ERRORFORWARD 等 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
3
login
refresh
health

属于基础设施接口,可以直接配置。

而:

1
2
GET /api/products/public/**
GET /api/articles/**

等业务 API,如果确实需要后台动态控制公开状态,可以放入数据库。

不要让后台管理员随手把:

1
/api/admin/**

配置成:

1
public_flag = true

否则权限中心就从“安全管理平台”变成了“一键卸门”。


默认允许还是默认拒绝

这是动态 URL 权限系统里最关键的设计之一。

假设开发人员新增:

1
2
3
@PostMapping("/api/admin/users/import")
public void importUsers() {
}

但是忘记向权限数据库登记。

此时有两种策略。

默认允许

1
2
3
没有规则

permit

优点是:

1
新增 API 不容易 403

缺点是:

1
新增 API 可能直接裸奔

默认拒绝

1
2
3
没有规则

403

这样新接口必须经过:

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
GET /api/orders/export

究竟应该匹配哪一个?

因此权限规则必须有确定的优先级。

推荐:

1
2
3
4
5
6
7
8
9
10
11
12
13
HTTP Method 精确匹配
>
ANY

EXACT
>
MVC / PathPattern
>
宽泛通配符

路径越具体
>
路径越宽泛

还可以增加:

1
priority INT

显式控制。

例如:

priority method pattern
100 GET /api/orders/export
80 GET /api/orders/{id}
10 GET /api/orders/**

授权规则绝不能依赖:

1
数据库碰巧返回的顺序

否则同一份权限数据换个执行计划,安全策略都可能跟着“量子叠加”。


Authentication 中应该存 Role 还是 Permission

可以只放角色:

1
2
ROLE_ADMIN
ROLE_FINANCE

然后每次授权:

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
hasRole("ADMIN")

与:

1
hasAuthority("ROLE_ADMIN")

通常表达相同的意思。

hasRole 会按照默认角色前缀处理 ROLE_

例如:

1
.roles("ADMIN")

最终通常生成:

1
ROLE_ADMIN

而:

1
new SimpleGrantedAuthority("ADMIN")

得到的就只是:

1
ADMIN

如果这时授权判断使用:

1
hasRole("ADMIN")

就会去匹配:

1
ROLE_ADMIN

自然无法匹配:

1
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 时尽量使用:

1
hasAuthority(...)

而不是把所有操作权限都设计成 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
}

现在管理员撤销:

1
api:order:delete

但旧 Token 里面依然存在:

1
api:order:delete

只要 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
rbac:api:rules

用户权限缓存

保存:

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]

正常请求完全不需要访问数据库。


多实例部署时权限缓存怎么刷新

单实例里:

1
ruleCache.refresh();

就够了。

但 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
AFTER_COMMIT

非常重要。

否则可能发生:

1
2
3
4
5
缓存先刷新

数据库事务回滚

缓存和数据库状态不一致

401 与 403 必须区分

前后端分离项目中经常统一返回:

1
2
3
4
{
"code": "AUTH_FORBIDDEN",
"message": "无权限访问"
}

但 HTTP 状态仍应该区分。

401 Unauthorized

表示:

1
你还没有完成有效认证

例如:

1
2
3
没有 Token
Token 无效
Token 已过期

403 Forbidden

表示:

1
2
Spring Security 已经知道你是谁
但你没有这个权限

例如:

1
2
当前用户有 api:order:list
请求却需要 api:order:delete

Spring Security 的异常翻译机制会根据认证状态,通过 AuthenticationEntryPointAccessDeniedHandler 将安全异常转换为对应 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 动态权限文章同样通过 AuthenticationEntryPointAccessDeniedHandler 分别处理未认证和未授权情况,并基于新的 Security 配置方式完成动态权限接入。


为什么 ControllerAdvice 经常接不到 Security 异常

典型请求链:

1
2
3
4
5
6
7
Servlet Filter

Spring Security

DispatcherServlet

Controller

@RestControllerAdvice 属于:

1
DispatcherServlet

内部的 Spring MVC 异常处理体系。

而认证、授权失败通常发生在:

1
Spring Security Filter Chain

此时请求甚至还没有进入 Controller。

因此不要期待:

1
@ExceptionHandler(AccessDeniedException.class)

一定能统一接住 Security Filter 阶段的所有异常。

认证和请求级授权应该优先使用:

1
2
AuthenticationEntryPoint
AccessDeniedHandler

这也是 Spring Security 自己提供的扩展点。


菜单权限、按钮权限和接口权限不要混为一谈

一个成熟后台系统通常同时存在:

1
2
3
菜单权限
按钮权限
API 权限

看上去都是“权限”,但它们解决的问题完全不同。

决定:

1
用户能不能看到某个页面

例如:

1
menu:system:user

BUTTON

决定:

1
页面是否展示操作入口

例如:

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
3
按钮隐藏
=
后端安全

前端按钮隐藏只能改善用户体验。

攻击者完全可以绕开前端直接:

1
2
curl -X DELETE \
https://example.com/api/users/100

因此:

后端 API 权限才是真正的安全边界。

用户已有的 RBAC 设计资料也特别强调了这一点:菜单决定页面入口,按钮决定前端操作入口,而后端强授权应始终以独立 API Permission 为依据;按钮和 API 最好采用多对多关系,而不是直接绑定成同一个权限对象。


一个按钮为什么可能对应多个 API

例如页面上的:

1
批量删除

用户点击以后前端可能执行:

1
2
3
4
5
GET /api/users/deletable

DELETE /api/users/batch

GET /api/users

因此:

1
btn:user:batch-delete

可能对应:

1
2
3
api:user:list-deletable
api:user:batch-delete
api:user:list

反过来:

1
2
3
新增用户
复制用户
快速创建

三个按钮也可能最终调用同一个:

1
POST /api/users

所以关系天然可能是:

1
Button N : N API

而不是:

1
Button 1 : 1 API

方法级权限作为第二道防线

请求级权限解决:

1
这个 HTTP 请求是否允许进入

但一些关键业务操作还适合增加方法级权限。

例如:

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) {

// ...
}
}

启用:

1
@EnableMethodSecurity

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
浏览器自动携带凭证

那么不能看到教程里有:

1
csrf.disable()

就直接复制。

判断依据应该是认证机制,而不是:

1
“我们用了 Vue,所以关 CSRF。”

Vue 对此表示自己很无辜。


OPTIONS 预检请求怎么处理

前后端跨域时,浏览器可能先发送:

1
OPTIONS /api/orders

很多旧示例直接:

1
2
.requestMatchers(HttpMethod.OPTIONS, "/**")
.permitAll()

可以工作,但更完整的做法是正确配置:

1
http.cors(Customizer.withDefaults());

并提供受控的:

1
CorsConfigurationSource

尤其不要生产环境组合:

1
2
3
Access-Control-Allow-Origin: *
+
Allow-Credentials: true

跨域策略应该与权限策略一起设计,而不是为了“让请求先通再说”把整个 CORS 配置点成无敌模式。


Spring Security 6 中 permitAll 比 ignoring 更合适

静态资源或公开资源过去经常配置:

1
web.ignoring()

现代 Spring Security 更倾向于让请求继续经过安全 Filter Chain,然后:

1
permitAll()

这样即使无需认证,安全 Header 等 Security 功能依然可以正常工作。

而 Spring Security 6 对 Authentication 的读取采用延迟策略,对于 permitAlldenyAll 这类无需身份信息的规则,可以避免不必要地读取认证信息。官方因此也推荐静态资源优先使用 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"
]
}

权限问题一旦发生,最重要的问题通常不是:

1
现在数据库里是什么?

而是:

1
谁在什么时候把它改成这样的?

权限系统建议采用 Fail Closed

可以把安全策略压缩成一句话:

1
2
3
没有明确允许
=
拒绝

包括:

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

而:

1
AntPathRequestMatcher

也已经进入废弃迁移阶段。


推荐的最终架构

综合前面的设计,一个比较完整的权限体系可以组织成:

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 解耦
权限扫描
审计
方法级二次授权
默认拒绝

真正稳定的权限系统不应该让业务代码到处充斥:

1
2
if (user.isAdmin()) {
}

也不应该让 Controller 到处硬编码:

1
hasRole("XXX")

更合理的边界是:

1
2
3
4
身份认证负责“你是谁”
RBAC 负责“你拥有什么能力”
API Permission 负责“这个操作需要什么能力”
AuthorizationManager 负责“这次请求能不能通过”

这样一来,权限配置可以动态调整,而 Spring Security 仍然负责真正的后端安全边界;角色、菜单、按钮、URL 和业务代码之间也不会互相绑死。

参考资料与版本说明

Spring Security 官方请求级授权文档说明了 AuthorizationFilterAuthorizationManagerauthorizeHttpRequestspermitAll、DispatcherType 授权以及自定义外部授权服务等机制。

Spring Security 6.5 API 文档显示,AuthorizationManager#authorize 已成为新的授权入口,而旧的 check 已进入 deprecated 状态。

对于路径匹配,Spring Security 6.5 新增 PathPatternRequestMatcher,同时将 AntPathRequestMatcher 标记为待移除。

Spring Security 方法级安全可以通过 @EnableMethodSecurity 开启,并支持 @PreAuthorize 等方法授权机制。

本文同时结合了提供的 Spring Security 资料中关于 Security Filter、PasswordEncoderROLE_ 前缀问题的分析,以及已有 RBAC1 设计中关于 Spring Security 6 动态授权、API 权限缓存、JWT 权限失效、菜单/按钮/API 解耦、默认拒绝和审计策略的工程实践。


Spring Security 6 动态 URL 权限控制:基于 AuthorizationManager 的 RBAC 实现
https://allendericdalexander.github.io/2026/08/13/java/spring/spring-security-dynamic-auth-rbac/
作者
AtLuoFu
发布于
2026年8月13日
许可协议