从零到上线:Spring Security在国企老系统的安全加固实战

码上见山
2026-04-13 22:37
阅读 1736

上周五下班前,产品总监突然把我叫住:“咱们那个内部审批系统,下周得给外部门开放权限了,安全认证这块你搞一下。”我表面镇定地点点头,内心已经翻了个白眼——这可是个跑了一年多的“祖传”单体应用,连登录页都是十年前的风格,现在要加权限控制?得,双休又悬了。

不过转念一想,反正咱是国企程序员,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,结果把简单问题复杂化。

对于我们的场景,我只做了三件事:

  1. 明确受保护的资源边界
  2. 定义清晰的角色权限映射
  3. 将认证与授权逻辑与业务完全分离

第一步:梳理资源清单

我把所有需要保护的接口按路径前缀分类:

路径前缀 是否公开 说明
/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(默认行为),只要用户还在操作就不会掉线。

日志与监控

我们在 CustomLogoutHandlerCustomAuthenticationSuccessHandler 里都加了日志,方便审计:

@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 或远程调用,一定要加缓存。


最后:为什么这次没加班?

说实话,这次能周五下班前搞定,全靠两点:

  1. 提前用 Claude 模拟了几套配置方案,避免了反复试错
  2. 坚持“最小改动”原则,没试图重构整个权限体系

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

最热最新
暂无评论
码上见山Lv.1
0
影响力
0
文章
0
粉丝