从零到上线:Spring Security在国企老系统的安全加固实战
上周五下班前,产品总监突然把我叫住:“咱们那个内部审批系统,下周得给外部门开放权限了,安全认证这块你搞一下。”我表面镇定地点点头,内心已经翻了个白眼——这可是个跑了一年多的“祖传”单体应用,连登录页都是十年前的风格,现在要加权限控制?得,双休又悬了。
不过转念一想,反正咱是国企程序员,deadline再急也不会真让我通宵。况且自从重度依赖 Claude 和 ChatGPT 写 CRUD 之后,搭个基础认证系统应该不至于肝到凌晨三点。毕竟在北京坐地铁一小时回家的路上,我已经用手机跟大模型对了好几轮方案,心里多少有点底。
今天这篇就聊聊我是怎么在一个老旧 Spring Boot 项目里快速集成 Spring Security,并且顺利通过安全审计的。不是教程,而是实打实的实战经验。
老系统的新难题
我们这套审批系统是典型的 Spring Boot + MyBatis 架构,数据库用的是 Oracle(别问,问就是历史包袱)。用户信息存在一张 user_info 表里,密码字段居然还是明文!运维同事上次看到直接报警了。
需求其实不复杂:
- 外部用户需要登录才能访问
- 不同角色(比如“申请人”、“审批人”、“管理员”)能看到不同菜单和操作按钮
- 所有接口必须鉴权,不能裸奔
- 支持登出和会话管理
听起来就是 Spring Security 的标准场景。但问题在于:我们不能动现有业务逻辑。领导原话是:“核心流程一个字都不能改,认证模块你自己想办法插进去。”
这就很考验架构设计能力了。Spring Security 强大归强大,但如果你一股脑把所有配置塞进 WebSecurityConfigurerAdapter(虽然现在已过时),很容易和原有 FilterChain 冲突,轻则 403 报错满天飞,重则整个系统瘫痪。
我见过隔壁组这么干,结果上线当天被测试疯狂提 Bug:“为什么我点‘提交’按钮跳到了登录页?”——因为他们的 AJAX 请求没配 CSRF 忽略,而前端又没带 token。
所以这次我决定:最小侵入,最大隔离。
架构设计思路:分层解耦,资源先行
Spring Security 的核心思想是“一切皆资源”。URL 是资源,方法调用是资源,甚至某个字段值也可以是资源(通过方法级安全)。但在实际落地时,很多团队一上来就搞 RBAC、搞 OAuth2,结果把简单问题复杂化。
对于我们的场景,我只做了三件事:
- 明确受保护的资源边界
- 定义清晰的角色权限映射
- 将认证与授权逻辑与业务完全分离
第一步:梳理资源清单
我把所有需要保护的接口按路径前缀分类:
| 路径前缀 | 是否公开 | 说明 |
|---|---|---|
/api/auth/** |
是 | 登录、登出等认证接口 |
/static/** |
是 | 静态资源(CSS/JS) |
/api/approve/** |
否 | 审批相关接口 |
/api/admin/** |
否 | 管理员专属接口 |
/actuator/** |
否(仅内网) | 健康检查,运维用 |
这个表格后来成了我和前端、测试对齐的“契约”。谁要是说“为什么我访问不了 /api/user/list”,我直接甩表:“你看这里写着要权限吧?”
第二步:权限模型简化
很多人一听到权限就想到“用户-角色-权限”三层模型。但在我们这种内部系统,角色种类不超过5个,完全没必要上动态权限分配。我直接采用 角色硬编码 + 注解控制 的方式:
// 在 Controller 方法上标注所需角色
@GetMapping("/admin/users")
@PreAuthorize("hasRole('ADMIN')")
public List<User> listUsers() {
return userService.getAll();
}
@PostMapping("/approve/submit")
@PreAuthorize("hasAnyRole('APPLICANT', 'OPERATOR')")
public Result submit(ApplyForm form) {
// ...
}
这样做的好处是:权限逻辑可见、可测、可追溯。不需要查数据库配置表,看代码就知道谁有权做什么。
当然,前提是你得开启方法级安全:
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig {
// 空配置类即可
}
实战:如何不踩坑地集成 Security
1. 别碰 WebSecurityConfigurerAdapter(它已经死了)
Spring Boot 2.7+ 已经弃用 WebSecurityConfigurerAdapter。新写法是直接提供 SecurityFilterChain Bean:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**", "/static/**").permitAll()
.requestMatchers("/actuator/**").hasIpAddress("10.0.0.0/8") // 仅内网可访问
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/api/auth/login") // 自定义登录接口
.successHandler(new CustomAuthenticationSuccessHandler())
.failureHandler(new CustomAuthenticationFailureHandler())
)
.logout(logout -> logout
.logoutUrl("/api/auth/logout")
.addLogoutHandler(new CustomLogoutHandler())
.logoutSuccessHandler(new CustomLogoutSuccessHandler())
)
.csrf(csrf -> csrf.disable()) // 注意:生产环境慎用!我们因全是 API 接口且无浏览器表单,故关闭
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1) // 同一账号只允许一个会话
.maxSessionsPreventsLogin(false) // 新登录踢掉旧会话
);
return http.build();
}
}
这里有几个关键点:
- CSRF 关闭:因为我们全是前后端分离的 API 调用(前端是 Vue),没有传统表单提交,且 Token 由后端生成并存储在 Session 中,所以可以安全关闭。但如果你有 HTML 表单,千万别关!
- Session 控制:国企系统特别在意“一人一号”,所以限制了最大会话数为 1。
- 自定义处理器:用于记录登录日志、清理缓存等,后面细说。
2. 用户认证:对接老数据库
Spring Security 默认用内存用户或 UserDetailsService。我们得对接 Oracle 的 user_info 表。
先写个 UserDetailsServiceImpl:
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserInfo user = userMapper.selectByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
// 注意:这里要把明文密码改成 BCrypt 加密!
// 我们在初始化脚本里批量加密了所有密码
return User.builder()
.username(user.getUsername())
.password(user.getPassword()) // 已加密
.roles(user.getRole().split(",")) // 角色字符串如 "APPLICANT,OPERATOR"
.build();
}
}
然后注入到 Security 配置中:
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService());
provider.setPasswordEncoder(passwordEncoder()); // 必须指定加密器
return provider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // 强烈建议用 BCrypt
}
血泪教训:千万别用 NoOpPasswordEncoder!我们之前测试环境用了,结果安全扫描直接红牌警告。现在所有新用户注册都强制用 BCrypt,老用户首次登录时自动迁移密码。
3. 登录接口怎么写?
很多人以为 Spring Security 只能处理 /login 表单提交。其实你可以完全自定义 RESTful 登录接口:
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@PostMapping("/login")
public Result login(@RequestBody LoginRequest req, HttpServletRequest request) {
// 这里其实什么都不用做!
// 因为 Spring Security 的 UsernamePasswordAuthenticationFilter 会自动拦截 /login POST 请求
// 并尝试认证。如果成功,会触发 successHandler;失败则 failureHandler
// 所以这个接口只是个“占位符”,实际逻辑在 Handler 里
throw new IllegalStateException("Should not be called directly");
}
@GetMapping("/me")
public UserInfo getCurrentUser(Authentication auth) {
if (auth != null && auth.getPrincipal() instanceof UserDetails) {
String username = ((UserDetails) auth.getPrincipal()).getUsername();
return userMapper.selectByUsername(username);
}
return null;
}
}
真正的登录逻辑在 CustomAuthenticationSuccessHandler:
@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
// 返回 JSON 而不是重定向
response.setContentType("application/json;charset=UTF-8");
PrintWriter out = response.getWriter();
out.write("{\"code\":200,\"msg\":\"登录成功\"}");
out.flush();
// 记录登录日志
String username = authentication.getName();
log.info("用户 {} 登录成功,IP: {}", username, getClientIP(request));
}
private String getClientIP(HttpServletRequest request) {
// 获取真实 IP(考虑 Nginx 代理)
String ip = request.getHeader("X-Forwarded-For");
return ip != null ? ip.split(",")[0] : request.getRemoteAddr();
}
}
这样前端就能拿到标准 JSON 响应,而不是被重定向到某个页面。
生产环境的那些“小动作”
会话超时与续期
国企系统经常有人开着页面去开会,回来发现要重新登录。我们把 Session 超时设为 2 小时,并在前端加了心跳检测:
# application-prod.yml
server:
servlet:
session:
timeout: 7200 # 2小时
同时,每次 API 请求都会自动续期 Session(默认行为),只要用户还在操作就不会掉线。
日志与监控
我们在 CustomLogoutHandler 和 CustomAuthenticationSuccessHandler 里都加了日志,方便审计:
@Override
public void onLogoutSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) {
if (authentication != null) {
String username = authentication.getName();
log.warn("用户 {} 主动登出,IP: {}", username, getClientIP(request));
}
}
这些日志接入 ELK,安全团队可以直接查“某用户是否异常登录”。
性能考量
Spring Security 的 FilterChain 是链式调用,每个请求都要过一遍。但我们实测发现,在千兆内网环境下,平均延迟只增加了 2~3ms,完全可以接受。
不过要注意:不要在 loadUserByUsername 里做复杂查询!我们这里只是单表查询,如果涉及多表 JOIN 或远程调用,一定要加缓存。
最后:为什么这次没加班?
说实话,这次能周五下班前搞定,全靠两点:
- 提前用 Claude 模拟了几套配置方案,避免了反复试错
- 坚持“最小改动”原则,没试图重构整个权限体系
Spring Security 其实是个很成熟的框架,文档也齐全。问题往往出在过度设计——一上来就想搞 JWT、OAuth2、动态权限,结果光调试 CORS 和 Token 刷新就能耗一周。
对于我们这种内部系统,基于 Session 的传统认证足够了。稳定、简单、易维护,这才是国企程序员的生存之道。
对了,上线第二天,产品总监发来微信:“小伙子不错,周末好好休息。”
我回了个“谢谢领导”,然后默默打开了《赛博朋克2077》——毕竟,双休不加班,才是程序员的终极安全感。
附:常用配置速查表
| 功能 | 配置方式 | 注意事项 |
|---|---|---|
| 公开路径 | requestMatchers(...).permitAll() |
路径匹配支持 Ant 风格(如 /api/public/**) |
| 角色控制 | hasRole('ADMIN') |
自动添加 ROLE_ 前缀,数据库存 ADMIN 即可 |
| IP 限制 | hasIpAddress("192.168.1.0/24") |
适合内网接口保护 |
| Session 超时 | server.servlet.session.timeout |
单位秒 |
| 密码加密 | BCryptPasswordEncoder |
强度默认 10,够用 |
| 方法级安全 | @PreAuthorize |
需启用 @EnableGlobalMethodSecurity |
希望这篇带点烟火气的总结,能帮你在下次接到“紧急安全需求”时,也能笑着准点下班。

评论 0