Spring Security上手就翻车?我在新东家两周踩过的坑全在这了

爬虫不想爬
2026-01-14 07:51
阅读 286

入职新公司才俩月,上周五晚上十点半,我还在会议室和前端小哥对认证接口。空调坏了,汗流浃背,脑子里却全是 403 Forbidden 的报错——这该死的 Spring Security,怎么又拦了我的 /api/user/profile

我是老京东人,干了五年后端,经历过三次双11零点洪峰、四次618大促压测,自认对高并发、限流、熔断这些“硬菜”还算拿手。但万万没想到,刚跳槽到这家创业公司(美其名曰“高速成长型科技企业”),第一个任务就是搭个用户认证系统,结果被 Spring Security 教做人。

更尴尬的是,我以前在京东基本靠内部框架,安全模块都是封装好的,这次裸奔上阵,才发现自己连 UsernamePasswordAuthenticationFilter 都没亲手写过。还好有 Claude 和 ChatGPT 救命,不然真得在新人试用期就被劝退了。

为啥突然要搞认证?还得兼容Python?

事情是这样的:公司之前是个纯 Python 后端团队,用 FastAPI + JWT 搞了一套登录逻辑。现在要上新业务,领导拍板:“后端语言统一成 Java,但旧接口不能动!” 于是我们 Java 组就得接锅——既要支持新前端(Vue3)调用新 API,又要让老 Python 服务能平滑对接认证体系。

产品经理还补刀一句:“下周三上线,行吧?” 我表面微笑,心里已经把 Spring Security 的文档翻烂了。

第一个坑:路径匹配规则把我整懵了

一开始我以为加个 @EnableWebSecurity 就完事了。结果一启动,所有接口都被拦了,连 /health 都返回 401。查了半天,发现新版 Spring Security(6.x)默认启用了 CSRF,并且 所有请求路径都受保护,除非你显式放行。

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable()) // 先关掉CSRF,前端是SPA,用不了这个
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatcher(new AntPathRequestMatcher("/actuator/**")).permitAll()
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        return http.build();
    }
}

注意这里用的是 requestMatchers() 而不是老版本的 antMatchers(),API 变了!而且如果你用了 WebSecurityCustomizer 去 ignore 路径,它会完全绕过 SecurityFilterChain,导致你的自定义过滤器也失效——这点坑了我整整一天。

第二个坑:JWT 解析失败,因为Header写错了

前端小哥按常规写了 Authorization: Bearer <token>,但我的解析逻辑一直报 Invalid JWT。最后发现,他在 Axios 拦截器里拼错了 Header 名,写成了 Authorizaton(少了个 r)…… 这种低级错误,测试居然没测出来!

但这也提醒我:认证系统的容错性必须强。于是我加了个前置过滤器,专门处理 Header 格式:

public class JwtHeaderNormalizationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                   HttpServletResponse response, 
                                   FilterChain chain) {
        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            // 正常流程
        } else if (authHeader != null && authHeader.contains(" ")) {
            // 兼容前端可能传错格式的情况
            String[] parts = authHeader.split(" ");
            if (parts.length == 2) {
                request = new HttpServletRequestWrapper(request) {
                    @Override
                    public String getHeader(String name) {
                        if ("Authorization".equalsIgnoreCase(name)) {
                            return "Bearer " + parts[1];
                        }
                        return super.getHeader(name);
                    }
                };
            }
        }
        chain.doFilter(request, response);
    }
}

别笑,这种“脏活”在真实项目里太常见了。毕竟前端同学可能同时维护三个项目,手抖是常态。

第三个坑:和Python服务共享Token,密钥管理炸了

旧 Python 系统用的是 HS256 签名,密钥写在 .env 里。我们 Java 服务也得用同一套密钥验签,否则用户登录后调 Python 接口会失败。

问题来了:密钥放哪儿?

  • application.yml?会被 Git 提交!
  • 放环境变量?运维说他们只支持 K8s Secret,但测试环境还没上 K8s……

最后我们折中:开发环境用 application-dev.yml(加 .gitignore),生产环境通过启动参数传入:

java -jar app.jar --jwt.secret=$(cat /run/secrets/jwt_secret)

顺便吐槽一句:永远不要相信“临时方案”。这个“临时”的密钥方案,上线三个月了还没改。

代码可读性救了我一命

因为注重可维护性,我把认证逻辑拆得很清楚:

  • JwtUtil:负责生成/解析 Token
  • UserDetailsServiceImpl:从 DB 加载用户(支持手机号/邮箱登录)
  • JwtAuthenticationFilter:拦截请求,提取 Token 并构建 Authentication
  • SecurityConfig:配置链路

这样即使后来同事接手,也能快速看懂。反观隔壁组那个把所有逻辑塞在一个 @Component 里的“天才”,上线后连他自己都改不动了。

前端联调:跨域问题差点让我辞职

你以为配好后端就完了?天真!前端本地开发用 http://localhost:3000,后端在 http://localhost:8080,浏览器直接给你一个 CORS error

Spring Security 的 CORS 配置必须 authorizeHttpRequests 之前声明,否则 OPTIONS 请求会被当成未认证直接拦掉:

http
    .cors(cors -> cors.configurationSource(corsConfigurationSource()))
    .csrf(csrf -> csrf.disable())
    .authorizeHttpRequests(...)

@Bean
CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOriginPatterns(List.of("http://localhost:*", "https://*.ourapp.com"));
    config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(Arrays.asList("*"));
    config.setAllowCredentials(true); // 注意:如果用了 credentials,allowed origins 不能是 *
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
}

重点:setAllowedOriginPatterns 要用 * 的话必须配合 setAllowCredentials(false),但我们用了 Cookie(虽然 STATELESS 但前端想存 refresh token),所以只能写死域名模式。

性能考虑:别让认证成为瓶颈

在京东时,我们双11前都会压测认证接口。这次虽然流量不大,但我还是做了两点优化:

  1. Redis 缓存用户信息:避免每次请求都查 DB
  2. Token 黑名单延迟失效:登出时不立即删 Token,而是等它自然过期(配合 short-lived access token + long-lived refresh token)
// 登出示例:只把 refresh token 加入黑名单,access token 任其过期
redisTemplate.opsForValue().set(
    "blacklist:refresh:" + refreshToken, 
    "true", 
    Duration.ofHours(7 * 24) // refresh token 有效期7天
);

这样既保证安全性,又减少 Redis 写压力。

最后说两句

折腾两周,终于跑通了。新前端能登录,老 Python 服务也能验签,测试小姐姐也没再提 bug。

回头想想,Spring Security 其实不难,难的是 理解它的 Filter Chain 执行顺序新版 API 的变化。网上很多教程还是基于 Spring Boot 2.x 的,照搬肯定翻车。

如果你也在新项目里踩坑,记住三点:

  • 别信旧文档,先看官方 Migration Guide
  • 联调时打开浏览器 Network 面板,看清楚到底是 401 还是 403
  • 和前端约好 Header、Body 格式,写进 Confluence,别靠口头约定

对了,听说下个月还要集成 OAuth2.0 支持微信登录…… 我已经开始肝了。要是再翻车,可能真得回京东搬砖了 😅

本文代码已脱敏,核心逻辑可复用。
环境:Spring Boot 3.2 + Spring Security 6.2 + JDK 17
作者:前京东后端,现某“高速成长”公司打工人,重度依赖 AI 但坚持手写注释

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝