跳到主内容
创建时间: 2026-10-04最后更新: 2026-10-04

JWT 能证明请求由谁发出,SCOPE_monitor 能决定这个身份是否可以进入监控 API,但都不能单独证明它可以修改哪一个监控分组。甲组成员即使猜到了乙组服务的 ID,也不应该读到或修改乙组的数据。

本章沿用 Java 21、Spring Boot 4.1.1 与 PostgreSQL,为已有 monitoring_group、monitored_service 增加成员关系。使用已经引入的 JdbcClient 和事务完成授权,不再增加权限框架。这里保护的是监控配置资源;按服务名称保存的旧心跳结果仍属于运维全局数据,不能直接当成已经隔离的用户历史。

1. 定义权限与响应

权限分成两层:安全过滤链检查 Token 和 Scope,应用服务检查数据库中的分组成员关系。

身份与关系读取修改 enabled
未登录401401
已登录但没有 monitor Scope403403
有 Scope,但不是目标分组成员404404
分组 VIEWER200403
分组 EDITOR200200

不存在的服务也返回 404,与「服务存在但不属于当前用户」保持一致;只读成员已经知道资源存在,所以修改时可以明确返回 403。列表直接过滤不可见行,不先返回全部数据再让前端隐藏。

用户标识采用 JWT 的 (iss, sub)。两个签发方可能拥有相同 sub,因此不能只保存 subject,也不能用昵称或邮箱代替稳定标识。应用只从经过认证的 Jwt 主体取得它们,忽略客户端自行提交的用户 ID。

2. 建立成员关系

前面的审计迁移使用了 V5,本章新增 V6。角色只允许 VIEWER、EDITOR,两者都可以读取,只有 EDITOR 可以修改监控配置。

src/main/resources/db/migration/V6__create_monitoring_membership.sql
01
create table monitoring_membership (
02
group_id bigint not null references monitoring_group(id) on delete cascade,
03
issuer varchar(255) not null,
04
subject varchar(255) not null,
05
role varchar(16) not null check (role in ('VIEWER', 'EDITOR')),
06
primary key (group_id, issuer, subject)
07
);
08
09
create index idx_monitoring_membership_actor
10
on monitoring_membership (issuer, subject, group_id);

保留 ddl-auto: validate,继续让 Flyway 管结构。成员的添加、降级和删除暂时由管理员执行受控 SQL;不开放让普通用户提交自己的角色或任意 group ID 的授权接口。下面的语句用于本地教学数据库,真实 issuer 和 subject 应来自身份平台的管理记录:

scripts/seed-monitoring-membership.sql
01
insert into monitoring_group (name) values ('backend')
02
on conflict (name) do nothing;
03
04
insert into monitored_service (name, group_id)
05
select 'inventory', id from monitoring_group where name = 'backend'
06
on conflict (name) do nothing;
07
08
insert into monitoring_membership (group_id, issuer, subject, role)
09
select id, 'https://id.example.test/realms/demo', 'alice', 'EDITOR'
10
from monitoring_group where name = 'backend'
11
on conflict (group_id, issuer, subject) do update set role = excluded.role;

URL 是示例身份平台标识,不是可用的公开登录服务。不要修改已执行的 V3、V5,也不要把真实用户授权写成所有环境都会执行的 Flyway 初始化数据。

3. 收紧 HTTP 入口

在已有 SecurityConfiguration.apiSecurity() 中,用下面的授权部分替换原来的 .authorizeHttpRequests(...)。JWT 解码、无状态会话、CORS 和 Bearer Token 的 CSRF 策略继续使用前文配置。

src/main/java/cn/usehook/spring/config/SecurityConfiguration.java
1
.authorizeHttpRequests(authorize -> authorize
2
.requestMatchers(HttpMethod.GET, "/api/v1/health").permitAll()
3
.requestMatchers("/actuator/health/**").permitAll()
4
.requestMatchers("/api/v1/monitoring/**").hasAuthority("SCOPE_monitor")
5
.requestMatchers("/api/v1/health/**", "/actuator/**")
6
.hasAuthority("SCOPE_monitor.admin")
7
.anyRequest().denyAll()
8
)

新入口使用 monitor Scope;旧全局历史和运维端点改为独立的 monitor.admin Scope,避免通过旧接口绕过分组隔离。认证服务器需要为运维客户端显式签发这个 Scope,原有测试也应按实际职责调整身份。公开 GET /api/v1/health 只返回本应用的非敏感存活信息。

同步修改已经写过的成功用例:JWT 篇访问旧 health 管理接口时,将 new SimpleGrantedAuthority("SCOPE_monitor") 改成 new SimpleGrantedAuthority("SCOPE_monitor.admin");端到端测试中的对应 Token 替身,将 .claim("scope", "monitor") 改成 .claim("scope", "monitor.admin")。原来检查权限不足的用例继续保留。下面的新 /api/v1/monitoring/** 测试仍使用 monitor,不能统一替换成管理员身份掩盖越权问题。

原有配置必须继续验证 issuer、audience、签名和有效期:

src/main/resources/application.yaml
01
spring:
02
security:
03
oauth2:
04
resourceserver:
05
jwt:
06
issuer-uri: ${AUTH_ISSUER_URI}
07
audiences: ${AUTH_AUDIENCE:https://springboot-api.usehook.cn}
08
mvc:
09
problemdetails:
10
enabled: true

Spring Security 将验证后的 JWT 暴露为认证主体;读取声明不等于重新实现 Token 验证。具体规则参见 JWT Resource Server 官方文档。

src/main/java/cn/usehook/spring/monitoring/MonitoringActor.java
01
package cn.usehook.spring.monitoring;
02
03
import org.springframework.http.HttpStatus;
04
import org.springframework.security.oauth2.jwt.Jwt;
05
import org.springframework.web.server.ResponseStatusException;
06
07
public record MonitoringActor(String issuer, String subject) {
08
public static MonitoringActor from(Jwt jwt) {
09
if (jwt == null || jwt.getIssuer() == null
10
|| jwt.getSubject() == null || jwt.getSubject().isBlank()) {
11
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
12
}
13
return new MonitoringActor(jwt.getIssuer().toString(), jwt.getSubject());
14
}
15
}

4. 把授权放进数据访问路径

下面是完整应用服务。列表最多返回 100 条可见配置,单条查询附带同样的成员条件。这里的列表是有上限的教学接口;需要翻页时继续携带相同授权条件,不能仅在第一页过滤。

src/main/java/cn/usehook/spring/monitoring/AuthorizedMonitoringService.java
01
package cn.usehook.spring.monitoring;
02
03
import java.util.List;
04
import org.springframework.http.HttpStatus;
05
import org.springframework.jdbc.core.simple.JdbcClient;
06
import org.springframework.stereotype.Service;
07
import org.springframework.transaction.annotation.Transactional;
08
import org.springframework.web.server.ResponseStatusException;
09
10
@Service
11
public class AuthorizedMonitoringService {
12
private final JdbcClient jdbc;
13
14
public AuthorizedMonitoringService(JdbcClient jdbc) {
15
this.jdbc = jdbc;
16
}
17
18
public record ServiceView(long id, String name, long groupId,
19
boolean enabled, long version) {}
20
21
private static final String VISIBLE = """
22
select s.id, s.name, s.group_id, s.enabled, s.version
23
from monitored_service s
24
join monitoring_membership m on m.group_id = s.group_id
25
where m.issuer = :issuer and m.subject = :subject
26
""";
27
28
public List<ServiceView> list(MonitoringActor actor) {
29
return visible(VISIBLE + " order by s.id limit 100", actor)
30
.query(ServiceView.class).list();
31
}
32
33
public ServiceView get(long id, MonitoringActor actor) {
34
return visible(VISIBLE + " and s.id = :id", actor)
35
.param("id", id).query(ServiceView.class).optional()
36
.orElseThrow(AuthorizedMonitoringService::hidden);
37
}
38
39
@Transactional(timeout = 5)
40
public ServiceView changeEnabled(long id, boolean enabled,
41
MonitoringActor actor) {
42
String role = visible("""
43
select m.role
44
from monitored_service s
45
join monitoring_membership m on m.group_id = s.group_id
46
where m.issuer = :issuer and m.subject = :subject and s.id = :id
47
for update of s, m
48
""", actor).param("id", id).query(String.class).optional()
49
.orElseThrow(AuthorizedMonitoringService::hidden);
50
51
if (!"EDITOR".equals(role)) {
52
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
53
"The resource is read-only for this member");
54
}
55
56
jdbc.sql("""
57
update monitored_service
58
set enabled = :enabled, version = version + 1,
59
updated_at = clock_timestamp()
60
where id = :id
61
""").param("enabled", enabled).param("id", id).update();
62
return get(id, actor);
63
}
64
65
private JdbcClient.StatementSpec visible(String sql, MonitoringActor actor) {
66
return jdbc.sql(sql).param("issuer", actor.issuer())
67
.param("subject", actor.subject());
68
}
69
70
private static ResponseStatusException hidden() {
71
return new ResponseStatusException(HttpStatus.NOT_FOUND,
72
"Monitoring resource not found");
73
}
74
}

FOR UPDATE OF s, m 同时锁住目标服务和当前成员行,锁一直持有到修改提交。管理员撤销成员或把 EDITOR 降为 VIEWER 时,其 DELETE/UPDATE 必须等待这次修改结束;如果撤销先提交,本次查询便没有可写成员。移动服务所属分组同样需要更新被锁住的服务行。这为权限变化和业务修改建立了明确先后顺序,而非承诺能撤销已经完成的操作。

这里不在持锁期间访问远程系统。所有这些 SQL 使用同一个 DataSource 和 Spring 管理的事务;继续使用项目已有的事务管理器,不创建第二套连接。直接 JDBC 更新不会触发 JPA 审计监听器,所以代码显式修改 updated_at 并递增 version;同一事务内不要混用已经加载的 JPA 实体来覆盖这行数据。

所有新的 HTTP 修改都必须进入这个服务,不能另留一个直接调用 JPA Repository 的公开入口。后续若增加成员管理、服务转组或批量更新,继续维护相同锁顺序并处理事务冲突。PostgreSQL 的锁行为见 显式锁官方文档。

5. Controller 只接收业务输入

请求体只包含 enabled,没有 issuer、subject 或 role;资源 ID 用来定位对象,不能用来决定当前用户身份。Boolean 配合 @NotNull 可区分没有传字段和明确传 false。

src/main/java/cn/usehook/spring/controller/MonitoringController.java
01
package cn.usehook.spring.controller;
02
03
import cn.usehook.spring.monitoring.AuthorizedMonitoringService;
04
import cn.usehook.spring.monitoring.AuthorizedMonitoringService.ServiceView;
05
import cn.usehook.spring.monitoring.MonitoringActor;
06
import jakarta.validation.Valid;
07
import jakarta.validation.constraints.NotNull;
08
import java.util.List;
09
import org.springframework.security.core.annotation.AuthenticationPrincipal;
10
import org.springframework.security.oauth2.jwt.Jwt;
11
import org.springframework.web.bind.annotation.*;
12
13
@RestController
14
@RequestMapping("/api/v1/monitoring/services")
15
public class MonitoringController {
16
private final AuthorizedMonitoringService service;
17
18
public MonitoringController(AuthorizedMonitoringService service) {
19
this.service = service;
20
}
21
22
public record ChangeEnabledRequest(@NotNull Boolean enabled) {}
23
24
@GetMapping
25
public List<ServiceView> list(@AuthenticationPrincipal Jwt jwt) {
26
return service.list(MonitoringActor.from(jwt));
27
}
28
29
@GetMapping("/{id}")
30
public ServiceView get(@PathVariable long id, @AuthenticationPrincipal Jwt jwt) {
31
return service.get(id, MonitoringActor.from(jwt));
32
}
33
34
@PatchMapping("/{id}/enabled")
35
public ServiceView change(@PathVariable long id,
36
@Valid @RequestBody ChangeEnabledRequest request,
37
@AuthenticationPrincipal Jwt jwt) {
38
return service.changeEnabled(id, request.enabled(), MonitoringActor.from(jwt));
39
}
40
}

ResponseStatusException 实现 Spring 的 ErrorResponse。前文的异常兜底处理器已经对 ErrorResponse 重新抛出,让问题详情处理器保留 403/404;不要改成捕获所有异常后统一返回 500。这里新端点直接返回 ServiceView,不改变旧心跳接口的响应包装。

6. 用越权测试证明边界

复用前文 TestcontainersConfiguration、Security 测试支持和 Web MVC 测试 Starter。以下测试保留真实授权服务、SQL、事务与数据库,只用 jwt() 建立测试身份。

src/test/java/cn/usehook/spring/monitoring/MonitoringAuthorizationTests.java
01
package cn.usehook.spring.monitoring;
02
03
import cn.usehook.spring.TestcontainersConfiguration;
04
import org.junit.jupiter.api.BeforeEach;
05
import org.junit.jupiter.api.Test;
06
import org.springframework.beans.factory.annotation.Autowired;
07
import org.springframework.boot.test.context.SpringBootTest;
08
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
09
import org.springframework.context.annotation.Import;
10
import org.springframework.jdbc.core.simple.JdbcClient;
11
import org.springframework.security.core.authority.SimpleGrantedAuthority;
12
import org.springframework.security.oauth2.jwt.JwtDecoder;
13
import org.springframework.test.context.bean.override.mockito.MockitoBean;
14
import org.springframework.test.web.servlet.MockMvc;
15
import org.springframework.test.web.servlet.request.RequestPostProcessor;
16
import org.springframework.transaction.annotation.Transactional;
17
import static org.assertj.core.api.Assertions.assertThat;
18
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
19
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
20
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
21
22
@SpringBootTest(properties = {
23
"app.monitoring.initial-delay=1h",
24
"app.monitoring.jobs.enabled=false",
25
"app.alerts.enabled=false",
26
"app.history.retention.enabled=false"
27
})
28
@AutoConfigureMockMvc
29
@Import(TestcontainersConfiguration.class)
30
@Transactional
31
class MonitoringAuthorizationTests {
32
@Autowired MockMvc mvc;
33
@Autowired JdbcClient jdbc;
34
@MockitoBean JwtDecoder decoder;
35
long serviceId;
36
37
@BeforeEach
38
void seed() {
39
long groupId = jdbc.sql("""
40
insert into monitoring_group(name) values ('authorization-test')
41
returning id
42
""").query(Long.class).single();
43
serviceId = jdbc.sql("""
44
insert into monitored_service(name, group_id)
45
values ('authorization-test', :group) returning id
46
""").param("group", groupId).query(Long.class).single();
47
jdbc.sql("""
48
insert into monitoring_membership(group_id, issuer, subject, role)
49
values (:group, 'https://id.example.test', 'alice', 'EDITOR'),
50
(:group, 'https://id.example.test', 'reader', 'VIEWER')
51
""").param("group", groupId).update();
52
}
53
54
private RequestPostProcessor as(String subject) {
55
return jwt().jwt(token -> token.issuer("https://id.example.test")
56
.subject(subject)).authorities(new SimpleGrantedAuthority("SCOPE_monitor"));
57
}
58
59
@Test
60
void outsiderCannotListReadOrModify() throws Exception {
61
mvc.perform(get("/api/v1/monitoring/services").with(as("bob")))
62
.andExpect(status().isOk()).andExpect(content().json("[]"));
63
mvc.perform(get("/api/v1/monitoring/services/{id}", serviceId).with(as("bob")))
64
.andExpect(status().isNotFound());
65
mvc.perform(patch("/api/v1/monitoring/services/{id}/enabled", serviceId)
66
.with(as("bob")).header("X-User-Id", "alice")
67
.contentType("application/json").content("{\"enabled\":false}"))
68
.andExpect(status().isNotFound());
69
assertThat(jdbc.sql("select enabled from monitored_service where id=:id")
70
.param("id", serviceId).query(Boolean.class).single()).isTrue();
71
}
72
73
@Test
74
void readerCannotWriteButEditorCan() throws Exception {
75
mvc.perform(get("/api/v1/monitoring/services/{id}", serviceId).with(as("reader")))
76
.andExpect(status().isOk());
77
mvc.perform(patch("/api/v1/monitoring/services/{id}/enabled", serviceId)
78
.with(as("reader")).contentType("application/json")
79
.content("{\"enabled\":false}")).andExpect(status().isForbidden());
80
mvc.perform(patch("/api/v1/monitoring/services/{id}/enabled", serviceId)
81
.with(as("alice")).contentType("application/json")
82
.content("{\"enabled\":false}"))
83
.andExpect(status().isOk()).andExpect(jsonPath("$.enabled").value(false));
84
}
85
86
@Test
87
void identicalSubjectFromAnotherIssuerHasNoMembership() throws Exception {
88
mvc.perform(get("/api/v1/monitoring/services/{id}", serviceId)
89
.with(jwt().jwt(token -> token.issuer("https://other.example.test")
90
.subject("alice"))
91
.authorities(new SimpleGrantedAuthority("SCOPE_monitor"))))
92
.andExpect(status().isNotFound());
93
}
94
}

这里 MockMvc 同步调用处于测试线程,@Transactional 可回滚该用例创建的数据;不要把这个结论套到前文 RANDOM_PORT 测试。jwt() 不验证签名,最后一项测试只证明数据库不会串用两个 issuer 的成员;当前单 issuer 生产配置会更早拒绝另一个签发方,返回 401。签名与 issuer 校验仍由 Resource Server 专门的测试和部署验证负责。

执行 ./mvnw -Dtest=MonitoringAuthorizationTests test,并继续增加:缺少 Token 返回 401、缺少 Scope 返回 403,以及不存在 ID 与不可见 ID 都返回相同 404。

7. 验证并发撤权

单线程越权测试无法证明锁行为。可在独立教学数据库打开两个 psql 会话,使用上面的 Alice 成员和 inventory 服务进行验证。

scripts/authorization-session-a.sql
1
begin;
2
select s.id, m.role
3
from monitored_service s
4
join monitoring_membership m on m.group_id = s.group_id
5
where s.name = 'inventory'
6
and m.issuer = 'https://id.example.test/realms/demo'
7
and m.subject = 'alice'
8
for update of s, m;
9
-- 保持事务打开,切换到会话 B;验证结束后执行 rollback。
scripts/authorization-session-b.sql
1
begin;
2
set local lock_timeout = '1s';
3
delete from monitoring_membership
4
where issuer = 'https://id.example.test/realms/demo' and subject = 'alice'
5
and group_id = (select id from monitoring_group where name = 'backend');
6
-- 预期锁超时,说明撤权不能插入“检查完成、写入尚未提交”的间隙。
7
rollback;

让会话 A rollback 后再次执行 B 的删除,便应成功;此时再用 Alice 的 Token 请求该服务应得到 404。测试结束按种子脚本恢复教学成员。撤权前已经完成的读取不会被追回,长时间导出和后台任务还需要明确各自的授权检查时点。

本章使用数据库过滤和短事务锁把权限落实到具体数据。相关 API 可查阅 JdbcClient 官方文档;如果以后引入缓存,缓存键和命中后的授权同样必须保留 (issuer, subject) 或分组权限边界,不能因为命中缓存就跳过检查。