从抗拒到真香:我用Spring Security给老系统加了道锁
说实话,一年前让我写 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(如果没特别配置)。
解决方案有两个:
- 改用 Token(如 JWT),彻底无状态
- 配置 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