《Spring Boot 实战》9.1 Spring Security 配置模型

从 SecurityFilterChain bean 与 lambda DSL 讲起,梳理 4.x 的 Security starter 改名、过滤器链的执行顺序与各自职责、401 与 403 的分工、密码编码器与 UserDetailsService 的落地写法,并给出多链共存的常见配置陷阱。

本节目标:讲清 Spring Security 7.1 的配置模型——为什么是 SecurityFilterChain bean 而不是 WebSecurityConfigurerAdapter,过滤器链按什么顺序执行,401 与 403 各自由谁负责,以及认证入口该怎么落地。
适用版本:Spring Boot 4.1.x(Java 21)

9.1 Spring Security 配置模型

入门卷在讲 Web 层时用的是「拦截器 + 手写 token 校验」的玩具方案:HandlerInterceptor 里取一次 header,比对字符串,然后把用户塞进 ThreadLocal。它够用,但它把三件事搅在一起——凭证解析、身份认证、权限判断,而且任何一处漏判都没有兜底。

本节把「图书借阅管理服务」的鉴权换成 Spring Security,并解释它在 4.x 下的配置模型。这个模型决定了后面两节能怎么落地:9.2 用它接入 JWT,9.3 用它做方法级授权。本节不讲「怎么配一个能跑的登录」,那种内容入门级教程遍地都是;本节讲的是配置结构为什么长这样,以及生产上最容易配错的地方。

9.1.1 先搞清楚 4.x 的 Security starter

4.0 的模块化重构把 Security 相关的 starter 全部改了名。旧名还在,但已废弃;正文一律用新名,否则你会在某次升级后收到一堆 deprecation 警告。

用途3.x 旧名(已废弃)4.x 新名
核心认证授权spring-boot-starter-securityspring-boot-starter-security(未改名)
OAuth2 资源服务器spring-boot-starter-oauth2-resource-serverspring-boot-starter-security-oauth2-resource-server
OAuth2 客户端spring-boot-starter-oauth2-clientspring-boot-starter-security-oauth2-client
授权服务器spring-boot-starter-oauth2-authorization-serverspring-boot-starter-security-oauth2-authorization-server
测试支持(随主 starter 引入)spring-boot-starter-security-test

两处容易踩的点:

  • 核心的 spring-boot-starter-security 没有改名,别画蛇添足写成 spring-boot-starter-security-core 之类不存在的坐标。
  • 测试相关的东西被拆了出去。@WithMockUser / @WithUserDetails 现在需要显式引入 spring-boot-starter-security-test 才能正常工作;只引主 starter 会在测试运行时拿到空的安全上下文。这一点官方迁移指南专门用 NOTE 标注过。

本节的依赖声明(pom.xml 片段,沿用第 1 章约定的父 POM 结构):

<dependencies>
  <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-data-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

只加 spring-boot-starter-security 一个依赖,应用就会立刻变「安全」:所有端点要求认证,未认证请求被重定向到 /login,并生成一个随机密码打在启动日志里。这是很多人第一次跑起来时的困惑来源——Security 是「默认全锁」而不是「默认放行」。

9.1.2 为什么是 SecurityFilterChain bean

3.x 之前的经典写法是继承 WebSecurityConfigurerAdapter,重写 configure(HttpSecurity http),然后 .authorizeRequests().antMatchers(...).and().formLogin().and()... 一路 and() 下去。

这条路已经完全走不通:

  • WebSecurityConfigurerAdapter 在 Spring Security 5.7 被废弃、6.0 被移除。
  • 非 lambda 的链式 DSL(authorizeRequests() + and())在 6.1 被废弃,7.0 起已移除。也就是说 and() 这种「用返回值继续链」的写法在 7.x 编译不过。

7.x 的模型只有一种:声明若干 SecurityFilterChain bean,每个 bean 描述一条独立的过滤器链。lambda DSL 是唯一风格,因为 lambda 的作用域天然区分了「配置 HttpSecurity 本身」和「配置某个子组件」。

package com.example.loan.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**")
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/books/**").hasAnyRole("LIBRARIAN", "ADMIN")
                .requestMatchers("/api/loans/**").authenticated()
                .anyRequest().authenticated())
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .httpBasic(Customizer.withDefaults())
            .csrf(csrf -> csrf.disable());
        return http.build();
    }
}

逐点解释这段配置的决策:

  • securityMatcher("/api/**") 限定这条链只处理 API 请求。后面还要加一条处理页面和静态资源的链,两条链靠 securityMatcher 分流,而不是靠 if 判断。
  • requestMatchers 是 6.x 起的统一入口(antMatchers / mvcMatchers 都已移除)。规则是从上到下第一个匹配生效,所以 permitAll 要放在宽泛的 authenticated() 之前。
  • SessionCreationPolicy.STATELESS 表示不创建、不读取 HttpSession。这是无状态 API 的前提,9.2 会展开。
  • csrf().disable() 对纯 token 认证的 API 是合理的:CSRF 攻击依赖浏览器自动携带 cookie,而 token 认证的凭证来自 Authorization header,不会被浏览器自动带上。但如果这条链同时支持 cookie 会话,关掉 CSRF 就是一个真实漏洞。

9.1.3 过滤器链的顺序与职责

HttpSecurity 配置的最终产物是一串 Filter。理解顺序比背 API 更重要,因为 401 和 403 由不同位置的组件产生。

相对顺序过滤器职责本节的配置开关
前SecurityContextHolderFilter从请求恢复 SecurityContext 到线程,请求结束清理自动装配
↓UsernamePasswordAuthenticationFilter处理表单登录 POST /loginformLogin()
↓BasicAuthenticationFilter处理 Authorization: BasichttpBasic()
↓BearerTokenAuthenticationFilter处理 Authorization: Bearer,交给 JWT 认证提供者oauth2ResourceServer()
↓ExceptionTranslationFilter捕获下游抛出的认证/授权异常,分派给 EntryPoint 或 AccessDeniedHandlerexceptionHandling()
后AuthorizationFilter执行 authorizeHttpRequests 里的授权规则authorizeHttpRequests()

关键点:AuthorizationFilter 在最下游。也就是说,认证类过滤器先跑完,把 Authentication 放进上下文,授权过滤器才根据 Authentication 决定放行还是拒绝。如果没有任何认证过滤器成功认证,上下文里是一个匿名 Authentication,授权规则按「匿名」判断——authenticated() 会失败,permitAll() 会通过。

多链共存时,链之间的顺序由 @Order 控制,数字小的先匹配:

@Bean
@Order(1)
SecurityFilterChain actuatorFilterChain(HttpSecurity http) throws Exception {
    http
        .securityMatcher("/actuator/**")
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/actuator/health/**").permitAll()
            .anyRequest().hasRole("ADMIN"))
        .httpBasic(Customizer.withDefaults());
    return http.build();
}

@Bean
@Order(2)
SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
    // 上面那条链已用 securityMatcher 拦下 /actuator/**,这里处理其余请求
    http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
    return http.build();
}

常见错误是两条链的 securityMatcher 出现重叠,或第一条链没有 securityMatcher 从而「吞掉」所有请求。排查方法是打开 logging.level.org.springframework.security=TRACE,日志会打印每次请求命中了哪条链。

9.1.4 401 与 403 的分工

这两个状态码经常被混用,但语义完全不同,且由不同组件产生:

场景含义产生者状态码
没有凭证 / 凭证无效「你是谁?」——未认证AuthenticationEntryPoint401
有凭证但权限不足「你不能做这个」——已认证但无授权AccessDeniedHandler403

默认情况下,未认证访问受保护资源会触发「重定向到登录页」,对浏览器友好但对 API 客户端是灾难(客户端拿到 302 而不是 401)。生产 API 必须显式提供 JSON 化的两个处理器。

package com.example.loan.security;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @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("UTF-8");
        response.getWriter().write("{\"code\":\"UNAUTHORIZED\",\"message\":\"缺少或无效的访问凭证\"}");
    }
}

@Component
public class RestAccessDeniedHandler implements AccessDeniedHandler {

    @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("UTF-8");
        response.getWriter().write("{\"code\":\"FORBIDDEN\",\"message\":\"当前角色无权执行该操作\"}");
    }
}

把它们接进配置:

http.exceptionHandling(ex -> ex
    .authenticationEntryPoint(restAuthenticationEntryPoint)
    .accessDeniedHandler(restAccessDeniedHandler));

一个反直觉的现象:已认证但无权限时,AccessDeniedHandler 有时会收到 401 的行为。原因是 Spring Security 对「匿名用户被拒绝」会走 EntryPoint(因为对匿名用户来说「重新认证」才有意义),只有「已认证用户被拒绝」才走 AccessDeniedHandler。所以两条路径都要覆盖,别只配一个。

9.1.5 密码编码器与 UserDetailsService

认证的本质是「根据用户名取出用户,比对密码」。这两个职责分别由 UserDetailsService 和 PasswordEncoder 承担。

密码编码器不要自己 new BCryptPasswordEncoder() 后到处传,用 PasswordEncoderFactories 生成委托编码器。它会输出带算法前缀的密文(如 {bcrypt}$2a$10$...),让将来换算法时老密码仍能验证。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class PasswordConfig {

    @Bean
    PasswordEncoder passwordEncoder() {
        // 默认 bcrypt,密文形如 {bcrypt}$2a$10$...
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

UserDetailsService 从 Member 表读用户,把角色映射成 GrantedAuthority:

package com.example.loan.security;

import com.example.loan.member.Member;
import com.example.loan.member.MemberRepository;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class JdbcMemberDetailsService implements UserDetailsService {

    private final MemberRepository members;

    public JdbcMemberDetailsService(MemberRepository members) {
        this.members = members;
    }

    @Override
    public UserDetails loadUserByUsername(String username) {
        Member member = members.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
        return User.withUsername(member.getUsername())
            .password(member.getPasswordHash())
            .roles(member.getRole().name())   // LIBRARIAN / READER / ADMIN
            .disabled(!member.isEnabled())
            .build();
    }
}

只要容器里同时存在 UserDetailsService 和 PasswordEncoder 两个 bean,Spring Boot 的自动配置就会组装出一个 DaoAuthenticationProvider 并接到表单登录 / Basic 认证上,无需手写 provider。注意 roles("LIBRARIAN") 会自动补上 ROLE_ 前缀,最终权限是 ROLE_LIBRARIAN;对应的判断要用 hasRole("LIBRARIAN"),而 hasAuthority 则要求你写全 ROLE_LIBRARIAN。混用这两个方法是「明明配了角色却 403」的最常见原因。

9.1.6 常见配置陷阱

  • 规则顺序写反:把 anyRequest().authenticated() 放在 permitAll() 之前,导致公开接口也要认证。规则自上而下第一个匹配生效。
  • 忘记 STATELESS:API 链没设 SessionCreationPolicy.STATELESS,Security 会为每个请求创建会话,无状态认证的意义被抵消,还会带来会话固定攻击面。
  • 同时开着 formLogin 和 httpBasic:浏览器访问 API 会被重定向到登录页。API 链应关掉 formLogin(),只保留 httpBasic() 或 oauth2ResourceServer()。
  • hasRole 与 hasAuthority 混用:见上一节,前缀差异会导致静默 403。
  • 测试缺 starter:@WithMockUser 不生效、安全上下文为空,通常是漏了 spring-boot-starter-security-test。

小结

Spring Security 7.1 的配置模型是「一个 bean 一条链、lambda 描述子组件」。WebSecurityConfigurerAdapter 与非 lambda 的 and() 链式 DSL 都已移除,网上大量旧教程在这两点上是过期的。生产 API 必须显式区分 401(AuthenticationEntryPoint)与 403(AccessDeniedHandler),并显式关闭会话创建。认证数据由 UserDetailsService 提供,密码由委托编码器处理,两者只要作为 bean 存在,Boot 就会自动组装 provider。

阅读导航:上一节:8.3 缓存一致性 · 下一节:9.2 JWT 无状态认证 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计