秋雨De blog

  • 首页
  • 留言板
  • 关于
  • rss
秋雨De blog
一个技术小白的个人博客
  1. 首页
  2. 未分类
  3. 正文

深入 Spring Security 6:从底层机制到动态模块化配置

2025年5月28日 1574点热度 0人点赞 0条评论

深入 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 实现都注释掉保留作参考。

本作品采用 知识共享署名 4.0 国际许可协议 进行许可
标签: 暂无
最后更新:2026年6月10日

fallrain

种一棵树最好的时间是十年前,其次是现在。

点赞
< 上一篇
下一篇 >

文章评论

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
取消回复

fallrain

种一棵树最好的时间是十年前,其次是现在。

文章目录
  • 深入 Spring Security 6:从底层机制到模块化配置
    • 一、Spring Security 的过滤链是怎么工作的
    • 二、Spring Security 6 的新配置方式
      • 为什么不用标准的 @ConditionalOnMissingBean 覆盖方式
    • 三、主配置类的设计
    • 四、各功能模块的 Customizer
      • 4.1 异常处理
      • 4.2 授权规则
      • 4.3 Headers 配置
      • 4.4 JWT 过滤器注册
    • 五、JWT 过滤器的实现
    • 六、Token 的存储与验证
    • 七、用户加载:TokenLoadUserService
    • 八、整体设计总结
    • 九、从 SecurityConfigurerAdapter 到 Customizer 的演进
友情连接
猫饭范文泉博客迎風别葉CODING手艺人ScarSu博友圈
归档
  • 2026 年 6 月
  • 2026 年 3 月
  • 2025 年 11 月
  • 2025 年 5 月
  • 2025 年 4 月
  • 2025 年 3 月
  • 2024 年 12 月
  • 2024 年 10 月
  • 2024 年 5 月
  • 2023 年 2 月
  • 2022 年 11 月
  • 2022 年 3 月
  • 2021 年 12 月
  • 2021 年 8 月
  • 2021 年 5 月
  • 2021 年 4 月
  • 2021 年 3 月
  • 2020 年 12 月
  • 2020 年 11 月
  • 2020 年 8 月
  • 2020 年 5 月
  • 2019 年 12 月
  • 2019 年 3 月

吉ICP备18007356号

吉公网安备22020302000184号

Theme Kratos Made By Seaton Jiang

COPYRIGHT © 2026 秋雨De blog ALL RIGHTS RESERVED