SpringBoot4系列18 - 使用Spring Security 7权限管理

运营后台的权限需求我接到过不止一次:客服查订单,管理员取消订单、翻审计。早年我偷懒,在每个接口开头手写身份判断,if (!currentUser().isAdmin()) 复制到十几个方法里,后来加一个角色要全局搜一遍 if,说实话我自己都说不清某个接口到底谁能调。身份认证与授权是「所有请求进业务之前都要过一遍」的横切关卡,这类活儿交给 Spring Security 的过滤器链正合适,我在这篇里把整套方案在 Spring Boot 4.1.1 加 Spring Security 7.1.1 上完整落了一遍。

https://static.xiongneng.me/security-filter-sequence-20260922041441.png

本文的工程是一个运营后台:SecurityFilterChain 按路径分角色,用户落 MySQL 存 {bcrypt} 密文,取消订单这类敏感操作收敛在服务层 @PreAuthorize,401 与 403 用 RFC 9457 风格的响应体区分开。18 个测试全绿,正文里的数字全部来自同一次运行,我留了底,你可以逐项对。

编程模型与自动配置的事实

写代码之前我习惯先把坐标、自动配置与 DSL 的现状确认清楚,免得照着过时的示例写。以下结论全部来自 Central 制品解包与 javap 实测。

Security 7 时代 starter 坐标没改名,spring-boot-starter-security 在 4.1.1 的 Central 目录里正常存在。组成我解包核对过(POM 实测):

1
2
3
4
5
6
7
spring-boot-starter-security      = spring-boot-starter + spring-boot-security
                                    + spring-aop 7.0.9
spring-boot-security              = spring-boot + spring-security-config 7.1.1
                                    + spring-security-web 7.1.1
spring-security-config            = spring-security-core(core 再带 spring-security-crypto)
spring-boot-starter-security-test = spring-boot-starter-security + spring-boot-starter-test
                                    + spring-boot-security-test

有两个细节我专门记了一笔。其一,Boot 4 把安全自动配置代码放进了独立模块 spring-boot-security,包名是模块化的 org.springframework.boot.security.autoconfigure,该模块的 AutoConfiguration.imports 我数了一遍共 10 条,和 Servlet 工程直接相关的 4 条是:

1
2
3
4
org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration
org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration
org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration
org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration

其二,spring-security-crypto 不在 starter 的直接依赖里,靠 config 到 core 再到 crypto 两跳传递进来。BCryptPasswordEncoder 运行期直接可用,不用额外引依赖,这是我解包时才确认的。

默认链的退让条件用 javap 解出来是两层。外层 ServletWebSecurityAutoConfiguration 标着 @ConditionalOnDefaultWebSecurity,内部再分两个配置类:

1
2
3
4
5
6
7
8
ServletWebSecurityAutoConfiguration            @ConditionalOnDefaultWebSecurity
  ├─ EnableWebSecurityConfiguration            贴 @EnableWebSecurity
  └─ SecurityFilterChainConfiguration
        @Bean defaultSecurityFilterChain(HttpSecurity)     @Order(2147483642)

@ConditionalOnDefaultWebSecurity = DefaultWebSecurityCondition(AllNestedConditions)
  ├─ Beans   @ConditionalOnMissingBean(SecurityFilterChain)
  └─ Classes @ConditionalOnClass({SecurityFilterChain, HttpSecurity})

工程里只要声明一个 SecurityFilterChain bean,Boot 的默认链整条退让,@EnableWebSecurity 也不用自己贴。 这里最容易读错的是退让粒度:EnableWebSecurityConfiguration 只按 bean 名 springSecurityFilterChain 退让,跟工程里自定义链的 bean 名无关,所以自定义链和它并不冲突,注解贴不贴都在。我第一遍读源码时把这条粒度理解错了,白琢磨了十分钟。

spring.security.* 前缀下的属性只有 5 条(spring-configuration-metadata 实证),比我想的少得多:

1
2
3
4
5
spring.security.filter.dispatcher-types   默认 [async, error, forward, include, request]
spring.security.filter.order              默认 -100
spring.security.user.name                 默认 user
spring.security.user.password             默认无(不配则启动日志打印随机密码)
spring.security.user.roles                默认无

user.* 三条归 UserDetailsServiceAutoConfiguration 消费:工程没有自己的 UserDetailsService bean 时,Boot 按它们造一个内存用户。DSL 现状也确认过:Security 7 的 HttpSecurity 上 authorizeHttpRequests、csrf、sessionManagement、formLogin、httpBasic、exceptionHandling 全部是 Customizer lambda 风格,没有 and() 链尾;路径匹配主力是 PathPatternRequestMatcher,位于 org.springframework.security.web.servlet.util.matcher。

https://static.xiongneng.me/security-starter-map-20260922041538.png

依赖和配置

工程四块依赖,是我按职责挑的:starter-webmvc 撑起 HTTP 接口面,starter-security 是本篇主角,starter-jdbc 加 MySQL 驱动管用户表与审计表落库:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

测试侧两个 starter,缺一不可,这个我踩过:security-test 提供 @WithMockUser 与 httpBasic 请求后处理器,但不含 @AutoConfigureMockMvc 本体,HTTP 级测试要另加 webmvc-test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc-test</artifactId>
    <scope>test</scope>
</dependency>

配置的关键一处是 spring.mvc.problemdetails.enabled=true,业务异常的 404 与 409 统一走 RFC 9457 的 application/problem+json。注意它管不到安全过滤器层的 401 与 403,原因在核心代码一节展开:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
spring:
  datasource:
    url: jdbc:mysql://192.168.1.97:3306/springboot4_security?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
    username: root
    password: root123456
  mvc:
    problemdetails:
      enabled: true
  sql:
    init:
      mode: always
      schema-locations: classpath:sql/schema.sql

表两张。sec_users 存登录名、密文、启用位与角色,t_audit_log 留敏感操作审计:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
CREATE TABLE IF NOT EXISTS sec_users
(
    username VARCHAR(64)  NOT NULL COMMENT '登录名',
    password VARCHAR(100) NOT NULL COMMENT '密文,带 {bcrypt} 前缀',
    enabled  TINYINT(1)   NOT NULL DEFAULT 1 COMMENT '1 可用 0 停用',
    role     VARCHAR(32)  NOT NULL COMMENT '角色名,不带 ROLE_ 前缀:ADMIN / OPERATOR',
    PRIMARY KEY (username)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE TABLE IF NOT EXISTS t_audit_log
(
    id         BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
    username   VARCHAR(64)  NOT NULL COMMENT '操作人',
    action     VARCHAR(64)  NOT NULL COMMENT '操作名',
    order_no   VARCHAR(32)  DEFAULT NULL COMMENT '关联订单号',
    result     VARCHAR(16)  NOT NULL COMMENT 'OK / DENIED / ERROR',
    detail     VARCHAR(200) DEFAULT NULL COMMENT '补充说明',
    created_at DATETIME(3)  NOT NULL COMMENT '写入时间',
    PRIMARY KEY (id),
    KEY idx_created (created_at)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

核心代码

https://static.xiongneng.me/security-filter-component-20260922041320.png

过滤器链:一处声明管住全部 HTTP 面

一个 SecurityConfig 类装下路径级授权、认证方式与会话策略,重点看 authorizeHttpRequests 的三条规则与 httpBasic 的 entry point 挂法:

 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
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http, SecurityProblemWriter problems) throws Exception {
        http
            // 纯 API + STATELESS 会话:没有浏览器表单、没有 Cookie 会话,
            // CSRF 保护没有作用对象
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                    // 审计记录只有管理员能读
                    .requestMatchers("/api/audit/**").hasRole("ADMIN")
                    // 订单查询与操作两种角色都放行,
                    // ADMIN 之外的收敛在服务层方法上做
                    .requestMatchers("/api/orders/**").hasAnyRole("OPERATOR", "ADMIN")
                    .anyRequest().authenticated())
            // 取舍:管理后台是纯 API,调用方是前端脚本与运维脚本,没有登录页可跳,
            // formLogin 的默认登录页与重定向对脚本不友好;httpBasic 每次请求都带凭据,
            // 无状态干净。entry point 必须挂在 httpBasic 上,原因见避坑指南坑一
            .httpBasic(basic -> basic.authenticationEntryPoint(problems::unauthorized))
            .exceptionHandling(e -> e
                    .authenticationEntryPoint(problems::unauthorized)
                    .accessDeniedHandler(problems::forbidden));
        return http.build();
    }
}

三条规则按具体路径在前、anyRequest 收尾的顺序排,先匹配到的先生效。认证方式在 httpBasic 与 formLogin 之间我选了 httpBasic:formLogin 面向浏览器,未登录时 302 跳登录页,对脚本调用方是灾难;httpBasic 未登录直接 401,配合 STATELESS 会话策略,服务端不留任何会话状态。两个自定义 Bean 接着登场:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Bean
PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

@Bean
UserDetailsManager userDetailsService(DataSource dataSource) {
    JdbcUserDetailsManager users = new JdbcUserDetailsManager(dataSource);
    users.setUsersByUsernameQuery(
            "SELECT username, password, enabled FROM sec_users WHERE username = ?");
    users.setAuthoritiesByUsernameQuery(
            "SELECT username, CONCAT('ROLE_', role) FROM sec_users WHERE username = ?");
    return users;
}

PasswordEncoder 用 DelegatingPasswordEncoder,按密文前缀 {bcrypt}、{noop}、{pbkdf2} 分发算法,新密文 encode 出来默认落 {bcrypt}。UserDetailsService 声明成 JDBC 版本后,Boot 的 UserDetailsServiceAutoConfiguration(spring.security.user.* 内存用户)整体退让。authorities 查询里 CONCAT('ROLE_', role) 是在补角色前缀,数据库存 ADMIN,授权表达式 hasRole("ADMIN") 内部按 ROLE_ADMIN 比较,两边口径必须对齐。

用户与密码:JDBC 用户加 {bcrypt} 密文

注册在我这里就是 BCrypt 编码后入库,明文只出现在种用户的一处代码里:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private void seed(String username, String rawPassword, String role) {
    Integer exists = jdbc.queryForObject(
            "SELECT COUNT(*) FROM sec_users WHERE username = ?", Integer.class, username);
    if (exists != null && exists > 0) {
        // 已有账号只打日志不覆盖(INSERT-OR-LOG 语义)
        return;
    }
    String hash = encoder.encode(rawPassword);
    jdbc.update("INSERT INTO sec_users(username, password, enabled, role) VALUES (?, ?, 1, ?)",
            username, hash, role);
    log.info("SEED_USER inserted username={} hash={} role={}", username, hash, role);
}

启动后 sec_users 里落了两行,密文取自实测日志 SEED_USER:

1
2
SEED_USER inserted username=admin    hash={bcrypt}$2a$10$NEA3Qmb8lB/0uKs96OH1zugsASxwZ81G3F2uYnv/NnLmOsvc0z33O role=ADMIN
SEED_USER inserted username=operator hash={bcrypt}$2a$10$9RLVpDGRVOCL2q5fjClKnuWRNlfCMw4IiUNFUYnR37GdWRooZe4Yy role=OPERATOR

密文前缀 {bcrypt}$2a$10$ 里藏着两个事实:算法是 bcrypt,强度是 10。强度不是配置出来的,是 BCryptPasswordEncoder 无参构造的默认值,javap 反汇编里 iconst_m1 之后换算成 log rounds 10。库里永远存带 {id} 前缀的密文,裸的 2a2a10$ 字符串没有前缀,认证时直接抛异常。 挺有意思的一点:BCrypt 自带的盐让同一明文每次编码结果都不同,我两次启动种出的密文不一样,认证照样通过,这是 matches 按盐重算的功劳,不是数据库里存了两个密码。

401 与 403:过滤器层的 problem+json

安全过滤器跑在 DispatcherServlet 之前,spring.mvc.problemdetails.enabled 管不到它们,401 与 403 的响应体要自己写,这是我一开始没料到的。自定义一个组件同时实现两种出口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public void unauthorized(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException ex) throws IOException {
    ProblemDetail pd = problem(HttpStatus.UNAUTHORIZED, "未登录", request,
            "请求缺少有效的 Basic 凭据或凭据无效");
    response.setStatus(HttpStatus.UNAUTHORIZED.value());
    // Basic 认证的 401 必须带 WWW-Authenticate,客户端靠它知道该用哪种方式认证
    response.setHeader("WWW-Authenticate", "Basic realm=\"ops-console\"");
    write(response, pd);
}

public void forbidden(HttpServletRequest request, HttpServletResponse response,
                      AccessDeniedException ex) throws IOException {
    ProblemDetail pd = problem(HttpStatus.FORBIDDEN, "无权访问", request,
            "当前账号的角色不足以访问该资源");
    response.setStatus(HttpStatus.FORBIDDEN.value());
    write(response, pd);
}

序列化用 Jackson 3 的 JsonMapper,构造时关掉 null 字段输出,ProblemDetail 没设置 properties 时响应体里就不会出现 "properties": null。type 字段按状态码映射成 urn:problem-type:unauthorized 与 urn:problem-type:forbidden,和 MVC 层渲染的业务异常响应体保持同一套风格。

401 与 403 的分界在认证:拿不出有效身份是 401,认证通过但角色不够才是 403。 用户名不存在对外伪装成凭据错误,同样落 401,不给探测账号清单的调用方留线索。

方法级:@PreAuthorize 收敛敏感操作

路径级规则把 /api/orders/** 放给 OPERATOR 之后,取消订单这种敏感操作要在更细的粒度收敛回 ADMIN:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@PreAuthorize("hasRole('ADMIN')")
public Order cancel(String orderNo) {
    Order order = store.get(orderNo);
    if (Order.STATUS_CANCELLED.equals(order.status())) {
        return order;
    }
    Order cancelled = new Order(order.orderNo(), order.amount(), Order.STATUS_CANCELLED,
            order.createdBy(), order.createdAt());
    store.update(cancelled);
    audit.record(currentUser(), "取消订单", orderNo, "OK", "订单已取消");
    log.info("ORDER_EVENT cancelled orderNo={} by={}", orderNo, currentUser());
    return cancelled;
}

注解拦在方法代理上,比控制器更靠近业务:就算调用方绕过 HTTP 直接注入 OrderService bean 调 cancel,代理照样抛 AccessDeniedException,我写了专门的单测验证这一点。路径级规则管进门,方法级注解管办事,两层各管一段。 配套的 @EnableMethodSecurity 已经贴在 SecurityConfig 类上,少这一个注解,所有 @PreAuthorize 静默失效。

认证事件:成功与失败都有日志行

ProviderManager 在认证出结果时发布事件,我监听成对的两个类型就够了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
    log.info("AUTH_EVENT success principal={} type={}",
            event.getAuthentication().getName(),
            event.getAuthentication().getClass().getSimpleName());
}

@EventListener
public void onFailure(AbstractAuthenticationFailureEvent event) {
    log.info("AUTH_EVENT failure principal={} reason={}",
            event.getAuthentication() == null ? "unknown" : event.getAuthentication().getName(),
            event.getException().getClass().getSimpleName());
}

两个事件类型都在 org.springframework.security.authentication.event 包。AUTH_EVENT 前缀的日志行是后面验证脚本与测试的对账依据,认证失败的原因(BadCredentialsException 这类)直接进了日志,安全告警可以按行订阅。

完整案例

我给这个运营后台定了一个最小权限矩阵:

接口OPERATORADMIN
GET /api/orders(订单列表)200200
POST /api/orders(创建订单)201201
POST /api/orders/{no}/cancel(取消订单)403200
GET /api/audit(审计查询)403200

启动应用,清空两张表,按场景打一轮。先看被拦下的三种:

1
2
3
4
GET  /api/orders                              匿名            -> 401
GET  /api/audit                               operator        -> 403
POST /api/orders/SK-SEC-1/cancel              operator        -> 403(方法级)
POST /api/audit                               admin + 错密码   -> 401

https://static.xiongneng.me/security-denied-panels-20260920103537.png

匿名请求的 401 与越权请求的 403 响应体逐字如下,instance 指向被拦的 URI,type 区分问题类别:

401 的响应头带着 WWW-Authenticate: Basic realm="ops-console",客户端靠它知道该补 Basic 凭据。两个 403 值得对照:读审计的 403 拦在过滤器层(路径级规则),取消订单的 403 拦在服务层方法代理(@PreAuthorize),出口都归到同一个 AccessDeniedHandler。同一个状态码,两层拦截点,排查时先看是哪一层拦的,能省一半时间。

再看放行的部分:

1
2
3
POST /api/orders  {"orderNo":"SK-SEC-2","amount":299.00}   operator -> 201
POST /api/orders/SK-SEC-2/cancel                           admin    -> 200
GET  /api/audit                                            admin    -> 200

https://static.xiongneng.me/security-granted-panels-20260920103542.png

建单响应里 createdBy 是 operator,取消响应里 cancelledBy 是 admin,取消成功后审计表落了 1 行:

https://static.xiongneng.me/security-audit-dataflow-20260922041517.png

审计行的数据流跟着取消操作走:@PreAuthorize 放行,业务改订单状态,AuditService 写库,id 从 1 开始说明是本轮首行。日志对账三项全对上:SEED_USER 2 行(两个账号的密文如上),AUTH_EVENT success 7 行、failure 1 行(错误密码那次),整个运行 ERROR 0 行。

测试怎么写

四个测试类覆盖四种能力,18 个用例全绿:

1
2
3
4
PasswordEncodingTest   4 个  BCrypt 编码与匹配、{noop} 前缀分发、库里密文实测
OrderApiSecurityTest   7 个  匿名/OPERATOR/ADMIN 三种身份打三类接口的全景
HttpBasicAuthTest      5 个  真实凭据链路:Basic -> JDBC 用户 -> {bcrypt} 密文
MethodSecurityTest     2 个  绕过 HTTP 直接调服务层,方法代理照样拦

真实认证链路用 httpBasic 请求后处理器打,凭据错误时连响应体和事件一起断言:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Test
void wrong_password_gets_401_with_failure_event(CapturedOutput output) throws Exception {
    mockMvc.perform(get("/api/audit").with(httpBasic("admin", "badpass")))
            .andExpect(status().isUnauthorized())
            .andExpect(jsonPath("$.type").value("urn:problem-type:unauthorized"))
            .andExpect(jsonPath("$.title").value("未登录"));

    assertThat(output).contains("AUTH_EVENT failure principal=admin");
    assertThat(output).contains("reason=BadCredentialsException");
}

这条测试同时验证了三件事:401 的 problem+json 响应体、失败事件发布、错误原因进日志。方法级安全的第二现场不经过 HTTP,@WithMockUser 把身份放进 SecurityContext 后直接调 bean:

1
2
3
4
5
6
@Test
@WithMockUser(username = "op1", roles = "OPERATOR")
void direct_service_call_is_stopped_for_operator() {
    assertThatThrownBy(() -> orderService.cancel("SK-DIRECT-1"))
            .isInstanceOf(AccessDeniedException.class);
}

@WithMockUser 验授权规则,真实凭据链路要单独测。 它直接把身份塞进上下文,不经过 UserDetailsService,JDBC 查询与 bcrypt 校验在这类测试里一次都没跑过,所以 HttpBasicAuthTest 用真凭据把整条链补上。两类测试我一开始想合一套里偷懒,后来发现它们根本不是一回事,分开学得踏实。

避坑指南

坑一,Basic 凭据错误的 401 不走 exceptionHandling。 这是最反直觉的一个:BasicAuthenticationFilter 认证失败时直接调自己构造时持有的 entry point,exceptionHandling 里配的 authenticationEntryPoint 拦不到这条路。解法是 entry point 挂两处:.httpBasic(basic -> basic.authenticationEntryPoint(...)) 管凭据错误,exceptionHandling 管匿名访问与授权失败。少挂第一处时,错误密码的响应体是空的,我第一次跑就栽在这里,盯着空响应体查了半天。

坑二,@AutoConfigureMockMvc 的包名要先核对。 4.1.1 里它的全限定名是 org.springframework.boot.webmvc.test.autoconfigure,import 写错时 IDE 会提示同名注解不存在,很多人第一反应是 Boot 删了功能,先查包名再下结论。

坑三,security-test starter 不含 @AutoConfigureMockMvc 本体。 spring-boot-starter-security-test 只把 Security 的 MockMvc 定制器登记进 AutoConfigureMockMvc.imports,注解本体在 webmvc-test 里。只引前者,测试类上贴 @AutoConfigureMockMvc 直接编译失败。

坑四,@WithMockUser 不经过 UserDetailsService。 它在测试启动时把 SecurityContext 直接放好,JDBC 用户查询、{bcrypt} 校验、DaoAuthenticationProvider 全部被跳过。授权规则(401/403 分工)用它测没问题,认证链路必须用 httpBasic 之类的真实凭据另测一遍,两类测试不能互相替代。

坑五,角色前缀两边要对齐。 hasRole(“ADMIN”) 内部按 ROLE_ADMIN 比较,JdbcUserDetailsManager 的 authorities 查询里要显式 CONCAT('ROLE_', role)。数据库直接存 ROLE_ADMIN 再 CONCAT 一次,或者查询里忘了 CONCAT,结果都是任何角色都匹配不上,所有受保护接口清一色 403。

坑六,裸 bcrypt 密文会让认证直接抛异常。 DelegatingPasswordEncoder 按密文前缀分发算法,库里存了不带 {bcrypt} 前缀的 $2a$10$... 时,认证阶段抛 IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"。存量数据迁移时给每行密文补前缀,或者注册期统一走 DelegatingPasswordEncoder 的 encode。

坑七,安全过滤器层的响应体要自己写。 401 与 403 发生在 DispatcherServlet 之前,spring.mvc.problemdetails.enabled 与 @RestControllerAdvice 都够不着。自定义 AuthenticationEntryPoint 与 AccessDeniedHandler,用 ProblemDetail 组织内容再手工序列化,null 字段用 NON_NULL 收掉,否则响应体里出现 "properties": null 这类噪音。

坑八,CSRF 关闭的前提是无表单加无会话。 本工程 STATELESS 会话加纯 API,CSRF token 没有作用对象,disable 是有依据的取舍。带浏览器表单或 Cookie 会话的工程照抄这行 disable,等于把跨站请求伪造的防线拆了,这类工程该保留 CSRF 并按框架默认行为走。

小结

权限管理这件事,我的选型建议是按三层来收:路径级规则写进 SecurityFilterChain,管谁进得了门;用户来源交给一个 UserDetailsService bean,数据库加 DelegatingPasswordEncoder 是省心组合;服务层的敏感操作用 @PreAuthorize 收细粒度。自动配置能替你贴的注解就别自己贴,自定义链一出现默认链整条退让,内存用户那几条属性在没有自定义用户源的工程里才生效。碰到带浏览器表单的项目,把 CSRF 和 formLogin 留住,别照搬这篇的无状态配置。

没解决的事也直说:这套方案用的是 HTTP Basic,凭据随每个请求明文传输,生产环境必须压在 HTTPS 下面;登录态、token 签发、密码策略、账号锁定这些我都没碰,权限模型也只到角色这一层,数据级行权限是另一个量级的活儿。这些留给后面的篇章,等我把 OAuth2 资源服务器那条路走通再来对账。

参考链接