从抗拒到真香:我用Spring Security给老系统加了道锁

事件循环乘客
2025-12-23 21:17
阅读 567

说实话,一年前让我写 Spring Security 的文章?我大概会翻个白眼然后继续敲我的 Python 脚本。

那时候我在公司后端组混日子,主力语言是 Java,但私下玩的全是 Python + FastAPI。Spring 那套“配置地狱”+“XML 阴影”让我敬而远之。更别说 Security——光看文档就头大,什么 AuthenticationManager、UserDetailsService、PasswordEncoder,一套接一套,感觉像是在解祖传保险箱。

转机发生在去年双11前两周。

我们那个跑了五年的老后台系统(对,就是那个连 HTTPS 都没配全的“裸奔”系统),突然被安全团队红牌警告:“再不加认证授权,下周就断网。”产品经理一脸无辜地补刀:“用户登录功能其实早就排进 Q3 了,你们后端是不是忘了?”

我差点把咖啡泼到键盘上。

那会儿团队就俩后端,另一个兄弟正在肝支付模块,根本抽不开身。领导拍我肩膀:“你不是爱折腾新技术吗?Security 就交给你了,周五上线。”

周五……还有三天。

没时间优雅,先跑起来再说

我第一反应是:能不能用现成的轮子?比如 Auth0、Firebase Auth?但运维大哥冷冷一句:“内网系统,不能连外网。”行吧。

翻出尘封已久的 Spring Boot 项目,建了个新分支,开始硬啃官方文档。说实话,Spring Security 的默认行为简直反人类——你啥都不配,它反而给你拦得死死的;你想让它放行某个接口,它偏要你写一堆 permitAll()。我当时就想:这玩意儿是不是故意劝退?

但人被逼到绝境,潜力就出来了。我打开 Claude(没错,我已经真香了),直接甩过去一句话:

“帮我生成一个最简 Spring Security 配置,支持用户名密码登录,用内存用户,允许 /login 和 /public 访问,其他都要认证。”

三秒后,代码出来了。虽然粗糙,但能跑!那一刻我承认:AI 真的是救命稻草。

不过生产环境肯定不能用内存用户。我们的用户数据在 MySQL 里,表结构还是十年前的老古董:user_id, username, password_md5……对,你没看错,MD5 加密,还没盐值。安全团队看到估计又要吐血。

把老破小数据库塞进 Security 的“现代”框架

Spring Security 默认要求 PasswordEncoder,而 MD5 根本不算“编码器”,只是哈希。我试过自定义一个 NoOpPasswordEncoder(别笑,真有这东西),但它在新版 Spring Security 里已经被 deprecated 了,而且强制要求非空密码。

最后的方案有点 hack,但有效:

@Bean
public PasswordEncoder passwordEncoder() {
    return new PasswordEncoder() {
        @Override
        public String encode(CharSequence rawPassword) {
            // 老系统注册走新流程,这里其实不会被调用
            return DigestUtils.md5DigestAsHex(rawPassword.toString().getBytes());
        }

        @Override
        public boolean matches(CharSequence rawPassword, String encodedPassword) {
            // 关键:用原始密码 MD5 后和数据库里的比
            String inputMd5 = DigestUtils.md5DigestAsHex(rawPassword.toString().getBytes());
            return inputMd5.equals(encodedPassword);
        }
    };
}

别喷,我知道这很丑。但现实是:系统重构排期到明年,现在先保上线。技术债?背就背吧,总比被安全断网强。

接着实现 UserDetailsService

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserDao userDao;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userDao.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("User not found");
        }
        // 注意:这里返回的 password 是数据库里的 MD5 值
        return User.builder()
                .username(user.getUsername())
                .password(user.getPassword()) // 存的是 MD5
                .authorities("ROLE_USER")
                .build();
    }
}

登录接口?Security 自带的表单太丑了

Spring Security 默认拦截 /login 并返回一个丑到爆的 HTML 表单。我们是纯 API 服务,前端是 Vue,根本不需要这个。

于是重写登录逻辑,用 JSON 交互:

@RestController
public class AuthController {

    @PostMapping("/api/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request, HttpServletRequest httpRequest) {
        try {
            UsernamePasswordAuthenticationToken token =
                new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword());
            
            Authentication auth = authenticationManager.authenticate(token);
            SecurityContextHolder.getContext().setAuthentication(auth);

            // 手动触发 Session 创建(如果用 Session)
            httpRequest.getSession();

            return ResponseEntity.ok(Map.of("status", "success"));
        } catch (BadCredentialsException e) {
            return ResponseEntity.status(401).body("Invalid credentials");
        }
    }
}

同时,在 Security 配置里放行 /api/login

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/login", "/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin().disable() // 禁用默认表单
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
            );
        return http.build();
    }
}

Python 党的反思:为什么不用 FastAPI?

写到这里,我忍不住想:要是当初用 Python + FastAPI,配合 JWT,半小时就能搞定。代码清爽,逻辑直白,连 MD5 都可以直接比。

但现实是:我们是个 Java 技术栈为主的中台团队。所有监控、日志、链路追踪都是基于 Spring Cloud 生态。换语言?运维不答应,SRE 不答应,连 CI/CD 流水线都要重写。

所以,与其抱怨框架复杂,不如学会和它共舞。Spring Security 虽然啰嗦,但胜在企业级、可扩展、审计友好。比如后面加 OAuth2、集成 LDAP、做细粒度权限控制,它都有成熟方案。而 FastAPI 在这些场景下反而要自己造轮子。

上线后的“惊喜”:Session 冲突与并发登录

周五晚上 9 点,系统上线。测试通过,我准备收拾电脑回家。

结果半夜 12 点,值班电话炸了:“用户反馈登出别人账号!”

排查发现:我们用了 Session 认证,而老系统多个 tab 页共用同一个浏览器 Session。当用户 A 登录后,用户 B 在同一台机器登录,会覆盖 Session,导致 A 被踢出。

这其实是 Spring Security 的默认行为——一个用户只能有一个活跃 Session(如果没特别配置)。

解决方案有两个:

  1. 改用 Token(如 JWT),彻底无状态
  2. 配置 Session 策略允许多设备登录

考虑到改造成本,我们选了方案 2:

.sessionManagement(session -> session
    .maximumSessions(10) // 允许最多10个并发
    .maxSessionsPreventsLogin(false) // 新登录不踢旧会话
)

顺便加了 Session 过期时间:

.sessionManagement(session -> session
    .sessionFixation().none()
    .invalidSessionUrl("/login?expired")
    .maximumSessions(10)
    .sessionRegistry(sessionRegistry())
)
@Bean
public SessionRegistry sessionRegistry() {
    return new SessionRegistryImpl();
}

总结:讨厌的框架,真香的功能

现在回头看,Spring Security 确实学习曲线陡峭,配置繁琐。但它提供的纵深防御能力,是快速脚本语言难以比拟的。

  • 默认防 CSRF(虽然我们关了,但知道它存在)
  • 密码编码强制化(逼我们正视加密问题)
  • 方法级权限 @PreAuthorize
  • 审计事件发布(可接入 ELK)

更重要的是,它让安全不再是“附加功能”,而是系统骨架的一部分

至于我?早就不抵触了。上周我还用 Claude 快速生成了一个 OAuth2 + JWT 的整合 demo,准备推给新项目。AI 写初稿,我调细节、压测、加监控——这才是现代程序员的真实工作流。

Python 我依然爱,但 Java + Spring Security,也成了我工具箱里不可或缺的一把瑞士军刀。

毕竟,能保住饭碗的技术,都是好技术


附:关键配置速查表

功能 配置项 说明
禁用 CSRF .csrf().disable() 前后端分离 API 通常关闭
放行路径 .requestMatchers("/public/**").permitAll() 多个路径可链式调用
自定义登录 禁用 formLogin() + 手写 Controller 返回 JSON 而非页面
Session 控制 maximumSessions(10) 避免用户被意外踢出
密码匹配 实现 PasswordEncoder.matches() 兼容老系统加密方式
权限注解 @EnableGlobalMethodSecurity(prePostEnabled = true) 开启 @PreAuthorize

最后一句真心话

如果你也在维护一个“祖传系统”,别怕动手加安全。哪怕只是最基础的登录认证,也能挡住 80% 的低级攻击。安全不是银弹,但它是底线。

而 Spring Security,虽然烦人,但真的管用。

评论 0

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