深入 Spring Security 6:从底层机制到模块化配置
Spring Security 是出了名的难配置。第一次接触它的时候,我对着那一堆 configure(HttpSecurity http) 完全不知道从哪下手,随便找了篇博客照抄,能跑就行。
后来项目变复杂了,这种方式开始撑不住——同一套安全配置要在多个服务里复用,每次都复制粘贴一遍,改一个地方要改好几处。更麻烦的是,Spring Security 6 把 WebSecurityConfigurerAdapter 废弃了,原来那套继承的方式直接不能用,得重新学。
于是我认真研究了一遍底层,最后把整套安全配置做成了一个框架 Starter,各业务服务引入依赖就能用,不需要自己写任何配置。这篇文章就是把这个过程拆开来讲。
一、Spring Security 的过滤链是怎么工作的
在写任何配置之前,得先搞清楚 Spring Security 的请求处理模型。
一个 HTTP 请求进来,Spring Security 本质上是一条过滤器链(Filter Chain)。请求从第一个 Filter 进去,经过一系列处理,最后到达你的 Controller。每个 Filter 各司其职:有的负责读取 Token、有的负责权限校验、有的负责处理异常。
这条链由 FilterChainProxy 管理。它是 Spring Security 注册到 Servlet 容器里的一个特殊 Filter,内部维护着多个 SecurityFilterChain。每个请求进来,FilterChainProxy 根据 URL 匹配找到对应的链,然后把请求交给那条链上的所有 Filter 依次处理。
HTTP 请求
│
▼
FilterChainProxy
│ 匹配 RequestMatcher
▼
SecurityFilterChain
│
├── CorsFilter
├── JwtAuthenticationFilter ← 自定义:读取 Token,填充 SecurityContext
├── BasicAuthenticationFilter
├── ExceptionTranslationFilter ← 捕获认证/授权异常
└── AuthorizationFilter ← 做权限决策
│
▼
DispatcherServlet → Controller
理解了这个模型,后面的配置就有了落脚点——我们所有的工作,本质上都是在往这条链上注册 Filter,或者配置各个 Filter 的行为。
二、Spring Security 6 的新配置方式
Spring Security 5 时代,大家都是继承 WebSecurityConfigurerAdapter 重写 configure(HttpSecurity http) 方法。Spring Security 6 把这个类废弃了,原因也很直接:继承方式太死板,一个类里堆所有配置,没法拆分复用。
新的方式是直接声明 SecurityFilterChain 的 Bean:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
这样写已经比继承好多了,但还有个问题——所有配置还是堆在一个方法里。项目复杂了之后,这个方法一样会膨胀成几百行。
HttpSecurity 提供了一个 Customizer<HttpSecurity> 接口,可以把不同的配置逻辑拆分成独立的 Bean,然后统一应用:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
List<Customizer<HttpSecurity>> customizers) throws Exception {
for (Customizer<HttpSecurity> customizer : customizers) {
customizer.customize(http);
}
return http.build();
}
Spring 会自动把容器里所有 Customizer<HttpSecurity> 类型的 Bean 收集到这个 List 里。每个 Customizer 只负责一块配置,主配置类完全不需要改。
这是我现在这套框架的核心思路。
为什么不用标准的 @ConditionalOnMissingBean 覆盖方式
做 Starter 的标准做法是这样:把默认配置全写在一个 SecurityFilterChain Bean 里,加上 @ConditionalOnMissingBean,业务方要定制就自己定义一个 SecurityFilterChain Bean 把默认的顶掉。
这种方式有一个很大的问题:覆盖粒度太粗。
假设业务方只是想改一下认证失败时的返回格式,用标准方式的话,必须把整个 SecurityFilterChain 重新写一遍——JWT Filter、Session 策略、授权规则全部重新声明,哪怕这些内容和框架默认的完全一样:
// 业务方只想改异常返回格式,却不得不把所有配置重写一遍
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.addFilterBefore(jwtFilter, BasicAuthenticationFilter.class) // 复制
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) // 复制
.exceptionHandling(e -> e // 只改这里
.authenticationEntryPoint(myCustomEntryPoint()));
return http.build();
}
用 Customizer<HttpSecurity> 的方式则完全不同,业务方只需要定义一个对应的 Customizer Bean,框架里其他的 Customizer 照常生效,互不干扰:
// 只覆盖异常处理这一块,其他配置完全不用管
@Bean
public Customizer<HttpSecurity> exceptionHandlingCustomizer() {
return http -> http.exceptionHandling(e -> e
.authenticationEntryPoint(myCustomEntryPoint())
);
}
这个设计思路和 Spring MVC 的 WebMvcConfigurer 很像——框架提供一堆默认行为,你只覆盖自己关心的那一块,其余的保持默认。把这个思路用到 Spring Security 上,就是现在这套方案。
三、主配置类的设计
SecturyConfig 是整个框架的入口,它的职责很简单——把所有 Customizer 应用到 HttpSecurity,然后 build:
@Bean
@ConditionalOnMissingBean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity,
List<Customizer<HttpSecurity>> httpSecurityCustomizers) throws Exception {
for (Customizer<HttpSecurity> customizer : httpSecurityCustomizers) {
customizer.customize(httpSecurity);
}
if (securityProperties.isDisableCsrf()) {
httpSecurity.csrf(AbstractHttpConfigurer::disable);
}
if (securityProperties.isDisableRequestCache()) {
httpSecurity.requestCache(AbstractHttpConfigurer::disable);
}
return httpSecurity.build();
}
CSRF 和 RequestCache 的开关放在 SecurityProperties 里,通过配置文件控制:
@ConfigurationProperties(prefix = "spring.security")
public class SecurityProperties extends org.springframework.boot.security.autoconfigure.SecurityProperties {
private boolean disableCsrf = true;
private boolean disableRequestCache = true;
}
默认都是关闭的,因为这套框架是给 JWT 无状态认证设计的,Session 和 CSRF 用不上。业务方如果有特殊需求,改配置文件就够了,不需要动代码。
@ConditionalOnMissingBean 这个注解很关键——如果业务方自己定义了 SecurityFilterChain Bean,框架的默认配置会自动让路。这是整套框架可覆盖设计的基础。
四、各功能模块的 Customizer
每个 Customizer 只负责一件事,通过 @ConditionalOnMissingBean 保证可以被业务方覆盖。
4.1 异常处理
认证失败(401)和权限不足(403)时,统一返回 JSON 格式:
@Bean
@ConditionalOnMissingBean
public Customizer<HttpSecurity> exceptionHandlingCustomizer() {
return http -> http.exceptionHandling(e -> e
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(200);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().print(JSONObject.toJSONString(
R.error(401, authException.getMessage())));
})
.accessDeniedHandler((request, response, authException) -> {
response.setStatus(200);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().print(JSONObject.toJSONString(
R.error(403, authException.getMessage())));
})
);
}
这里有个细节:response.setStatus(200) 而不是 401/403。原因是某些前端框架对非 200 的响应会有特殊处理(比如直接跳转登录页),统一返回 200 再在 body 里带业务状态码,更可控。
4.2 授权规则
默认放行所有请求,业务方根据需要覆盖:
@Bean
@ConditionalOnMissingBean
public Customizer<HttpSecurity> authorizeHttpRequestsConfigurer() {
return http -> http.authorizeHttpRequests(
auth -> auth.anyRequest().permitAll()
);
}
框架默认全放行,是因为这是一个通用 Starter,不知道业务方哪些接口需要认证。业务方如果需要权限控制,自己定义一个同类型 Bean 覆盖掉就行。
4.3 Headers 配置
禁用 X-Frame-Options,允许页面被 iframe 嵌入(一些后台系统有这个需求):
@Bean
public Customizer<HttpSecurity> headersConfigurer() {
return http -> http.headers(
headers -> headers.frameOptions(
HeadersConfigurer.FrameOptionsConfig::disable)
);
}
4.4 JWT 过滤器注册
@Bean
public Customizer<HttpSecurity> jwtFilterConfigurer() {
JwtAuthenticationFilter jwtAuthenticationFilter =
new JwtAuthenticationFilter(tokenProperties);
return http -> http.addFilterBefore(
jwtAuthenticationFilter, BasicAuthenticationFilter.class);
}
把 JwtAuthenticationFilter 插到 BasicAuthenticationFilter 之前。这个插入位置是有讲究的——BasicAuthenticationFilter 是 Spring Security 处理认证的标准位置,在它之前插入自定义 Token 过滤器,能保证我们的认证逻辑先于 Spring 默认的认证逻辑执行。
五、JWT 过滤器的实现
JwtAuthenticationFilter 继承 OncePerRequestFilter,保证每个请求只执行一次:
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProperties tokenProperties;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 从请求头取 Token
String token = request.getHeader(tokenProperties.getTokenHeader());
if (Objects.isNull(token)) {
filterChain.doFilter(request, response);
return;
}
// 解析 Token 获取 uid
String uid = TokenUtils.getUid(token);
if (Objects.isNull(uid)) {
filterChain.doFilter(request, response);
return;
}
// 把认证信息写入 SecurityContext
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(uid, null, null);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
}
逻辑很直接:取 Token → 解析 uid → 写入 SecurityContext → 放行。
没有 Token 或者 Token 无效,直接放行(不写 SecurityContext),后续的 AuthorizationFilter 会根据该接口的权限要求决定是否拒绝请求。这样 Filter 只负责"认证",不负责"拦截",职责清晰。
六、Token 的存储与验证
Token 采用 JWT + Redis 双重验证。纯 JWT 有个问题:签发出去就没法撤销,用户退出登录后 Token 在过期之前仍然有效。引入 Redis 做"有效 Token 注册表",就能随时让 Token 失效。
TokenServiceImpl 的核心逻辑:
public String generateSystemToken(String uid) {
String token = Jwts.builder()
.subject("system")
.claim("uid", uid)
.claim("random", Math.random()) // 加随机数,同一用户每次生成不同 Token
.signWith(secretKey, Jwts.SIG.HS256)
.compact();
// 存两条记录:uid → token(用于踢掉旧 token),token → ""(用于校验)
redisTemplate.opsForValue().set(
"%s::%s".formatted(tokenProperties.getUserPos(), uid),
token, tokenProperties.getTokenTime(), TimeUnit.MINUTES);
redisTemplate.opsForValue().set(
"%s::%s".formatted(tokenProperties.getTokenPos(), token),
"", tokenProperties.getTokenTime(), TimeUnit.MINUTES);
return token;
}
Redis 里存了两条记录:uid → token 和 token → ""。
uid → token 这条记录的作用:同一个用户重新登录时,新 token 会覆盖旧的,旧 token 在下次校验时发现 Redis 里的 uid 对应的 token 已经不是自己了,自动失效。这样实现了"同一用户只允许一个 token 有效"的效果,不需要额外的踢人逻辑。
验证 Token 时:
public String getUid(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(secretKey).build()
.parseSignedClaims(token).getPayload();
// 校验 subject,防止不同用途的 token 混用
if (!Objects.equals(claims.getSubject(), tokenProperties.getSubject())) {
return null;
}
String uid = claims.get("uid", String.class);
// 从 Redis 取该 uid 对应的有效 token,同时顺手续期
String redisToken = (String) redisTemplate.opsForValue()
.getAndExpire(
"%s::%s".formatted(tokenProperties.getUserPos(), uid),
tokenProperties.getTokenTime(), TimeUnit.MINUTES);
// 对比:如果 Redis 里的 token 和传入的不一致,说明已被新登录覆盖
if (!Objects.equals(token, redisToken)) {
redisTemplate.delete(
"%s::%s".formatted(tokenProperties.getTokenPos(), token));
return null;
}
// 续期 token 本身的过期时间
redisTemplate.expire(
"%s::%s".formatted(tokenProperties.getTokenPos(), token),
tokenProperties.getTokenTime(), TimeUnit.MINUTES);
return uid;
} catch (Exception e) {
return null;
}
}
每次成功校验都会续期,实现了"活跃用户不掉线"的效果。
七、用户加载:TokenLoadUserService
业务系统里往往有多种用户类型——普通用户、管理员、商家……每种用户的数据来源和加载方式不同。
框架提供了 TokenLoadUserService<T> 接口,各业务模块实现后注册到容器,TokenUtils 在需要时动态找到对应的加载器:
@Component
public class TokenUtils {
private static final Map<Class<?>, TokenLoadUserService<?>> serviceMap
= new ConcurrentHashMap<>();
public TokenUtils(TokenService tokenService,
List<TokenLoadUserService<?>> serviceList) {
TokenUtils.tokenService = tokenService;
// 启动时把所有实现按 userType 建立索引
serviceList.forEach(s -> serviceMap.put(s.getUserType(), s));
}
public static <T> T getUser(Class<T> clazz) {
TokenLoadUserService<T> service =
(TokenLoadUserService<T>) serviceMap.get(clazz);
if (service == null) {
throw new IllegalArgumentException(
"No TokenLoadUserService registered for: " + clazz.getName());
}
return service.loadUser(getUid());
}
}
使用时只需要:
User user = TokenUtils.getUser(User.class);
框架根据 User.class 找到对应的 TokenLoadUserService<User> 实现,调用 loadUser(uid) 加载用户。业务方只需要实现一个 UserTokenLoadService extends AbstractTokenLoadUserService<User> 就行,泛型类型信息通过反射自动提取:
public abstract class AbstractTokenLoadUserService<T> implements TokenLoadUserService<T> {
private final Class<T> userType;
@SuppressWarnings("unchecked")
public AbstractTokenLoadUserService() {
// 通过反射获取泛型实际类型,子类不需要手动传入 Class
this.userType = (Class<T>) ((ParameterizedType) getClass()
.getGenericSuperclass())
.getActualTypeArguments()[0];
}
}
八、整体设计总结
把整套设计串起来:
框架 Starter
│
├── SecturyConfig(主配置)
│ └── 收集所有 Customizer<HttpSecurity> → 应用 → build
│
├── Customizer<HttpSecurity> Bean 列表
│ ├── exceptionHandlingCustomizer (异常处理,@ConditionalOnMissingBean)
│ ├── authorizeHttpRequestsConfigurer(授权规则,@ConditionalOnMissingBean)
│ ├── headersConfigurer (Headers 配置)
│ └── jwtFilterConfigurer (注册 JwtAuthenticationFilter)
│
├── JwtAuthenticationFilter
│ └── 读 Token → 解析 uid → 写 SecurityContext
│
├── TokenServiceImpl
│ └── JWT 生成/校验 + Redis 双重验证 + 自动续期
│
└── TokenUtils
└── 按 Class 动态找 TokenLoadUserService → 加载用户对象
对业务方透明:引入 Starter,不写任何 Security 配置,默认全放行 + JWT 认证就能用。
可覆盖:所有关键 Bean 都是 @ConditionalOnMissingBean,业务方自定义 Bean 就能替换框架默认行为,不需要改框架代码。
可扩展:新增一个 Customizer<HttpSecurity> Bean,框架自动纳入,主配置类不用动。比如要加短信登录,只需要新写一个 SmsLoginCustomizer,框架下次启动时自动应用。
九、从 SecurityConfigurerAdapter 到 Customizer 的演进
代码里注释掉了大量旧实现,这些注释其实记录了一段演进过程。
最初我用的是 SecurityConfigurerAdapter 的方式,每个功能模块继承这个抽象类,在 configure(H builder) 里写配置逻辑。这是 Spring Security 底层原生支持的扩展方式,没有问题,但写起来有点重——需要继承特定类、泛型声明也比较啰嗦。
后来发现 Customizer<HttpSecurity> 更轻量,只是一个函数式接口,用 lambda 就能实现,不需要继承任何类,注入到 List<Customizer<HttpSecurity>> 的方式也更自然。两种方式底层都是往 HttpSecurity 里注册配置,效果完全一样,Customizer 写起来更简洁。
最后统一换成了 Customizer 的方式,旧的 SecurityConfigurerAdapter 实现都注释掉保留作参考。
文章评论