本节目标:讲清 Spring Security 7.1 的配置模型——为什么是
SecurityFilterChainbean 而不是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-security | spring-boot-starter-security(未改名) |
| OAuth2 资源服务器 | spring-boot-starter-oauth2-resource-server | spring-boot-starter-security-oauth2-resource-server |
| OAuth2 客户端 | spring-boot-starter-oauth2-client | spring-boot-starter-security-oauth2-client |
| 授权服务器 | spring-boot-starter-oauth2-authorization-server | spring-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 认证的凭证来自Authorizationheader,不会被浏览器自动带上。但如果这条链同时支持 cookie 会话,关掉 CSRF 就是一个真实漏洞。
9.1.3 过滤器链的顺序与职责
HttpSecurity 配置的最终产物是一串 Filter。理解顺序比背 API 更重要,因为 401 和 403 由不同位置的组件产生。
| 相对顺序 | 过滤器 | 职责 | 本节的配置开关 |
|---|---|---|---|
| 前 | SecurityContextHolderFilter | 从请求恢复 SecurityContext 到线程,请求结束清理 | 自动装配 |
| ↓ | UsernamePasswordAuthenticationFilter | 处理表单登录 POST /login | formLogin() |
| ↓ | BasicAuthenticationFilter | 处理 Authorization: Basic | httpBasic() |
| ↓ | BearerTokenAuthenticationFilter | 处理 Authorization: Bearer,交给 JWT 认证提供者 | oauth2ResourceServer() |
| ↓ | ExceptionTranslationFilter | 捕获下游抛出的认证/授权异常,分派给 EntryPoint 或 AccessDeniedHandler | exceptionHandling() |
| 后 | 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 的分工
这两个状态码经常被混用,但语义完全不同,且由不同组件产生:
| 场景 | 含义 | 产生者 | 状态码 |
|---|---|---|---|
| 没有凭证 / 凭证无效 | 「你是谁?」——未认证 | AuthenticationEntryPoint | 401 |
| 有凭证但权限不足 | 「你不能做这个」——已认证但无授权 | AccessDeniedHandler | 403 |
默认情况下,未认证访问受保护资源会触发「重定向到登录页」,对浏览器友好但对 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 无状态认证 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。