Spring Security上手踩坑实录:一个考公程序员的自救指南

韩建军○
2026-01-03 14:42
阅读 712

上周五晚上十一点,我盯着屏幕上刺眼的403 Forbidden错误,差点把机械键盘砸了。这不是第一次被权限问题折磨——但这次真的不一样。领导临时拍板,要把我们内部管理系统开放给合作方使用,要求“下周上线”,还补了一句:“安全方面你看着搞,别出事就行。”

我?一个主业写业务逻辑、副业刷行测申论的Java后端,在上海合租屋里一边改bug一边背常识判断的打工人,现在要硬刚认证授权体系?

没办法,为了不耽误周末去图书馆刷题的时间,我只能速通Spring Security。还好有Claude在旁边当外挂(感谢GFW没完全封死),不然真得通宵。


说起来,我们团队之前的安全方案挺“野路子”:自己用JWT写了个拦截器,用户信息塞进ThreadLocal,权限校验靠if-else硬编码。双11期间差点因为token过期逻辑漏洞被渗透测试打穿——运维老哥当时看我的眼神,仿佛在看一个定时炸弹。

痛定思痛,技术债总得还。这次需求正好是个契机。可选方案其实不少:

  • Shiro:轻量,上手快,但社区活跃度一般,文档老旧;
  • 自研JWT+拦截器:熟悉但脆弱,扩展性差;
  • Spring Security:重量级选手,生态强大,但学习曲线陡峭。

考虑到项目已经重度依赖Spring Boot,而且未来可能接入OAuth2、LDAP等企业级认证方式,最终还是咬牙选了Spring Security。毕竟——万一考上公务员,这段经历也能写进简历里“主导安全架构升级”嘛(笑)。


从零搭建:别被默认配置吓到

很多人一看到Spring Security就退缩,因为它默认开启CSRF、Session管理、表单登录……一堆功能一股脑全开,本地跑起来连接口都调不通。我当时就懵了:我只是想加个登录接口,怎么连Swagger都403了?

关键在于先禁用不需要的功能,快速跑通主流程。我的起步配置长这样:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable() // 先关掉,后面再按需开启
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
                .anyRequest().authenticated()
            )
            .httpBasic().disable()
            .formLogin().disable();
        
        return http.build();
    }
}

注意几个关键点:

  1. STATELESS会话策略:我们是纯API服务,不需要服务器维护Session;
  2. 放行认证接口和Swagger:不然开发体验直接负分;
  3. 禁用CSRF和表单登录:RESTful API用不到这些。

这时候启动项目,你会发现除了/api/auth/**,其他接口都返回401。很好,第一步成功了——至少不是403了!


认证流程:从UsernamePasswordAuthenticationToken说起

Spring Security的核心抽象是Authentication对象。当你调用/login接口时,需要手动构造一个未认证的UsernamePasswordAuthenticationToken,交给AuthenticationManager去验证:

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest req) {
    Authentication authentication = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword())
    );
    
    // 验证通过后,生成JWT返回
    String token = jwtUtil.generateToken(authentication);
    return ResponseEntity.ok(new AuthResponse(token));
}

AuthenticationManager背后干活的是UserDetailsService。你需要实现它,告诉框架“用户数据从哪来”:

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepo;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepo.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found"));
        
        return org.springframework.security.core.userdetails.User.builder()
            .username(user.getUsername())
            .password(user.getPassword()) // 注意:数据库存的是BCrypt加密后的密码
            .authorities("ROLE_USER") // 简化处理,实际应查角色表
            .build();
    }
}

这里有个坑:密码必须是BCrypt加密的!如果你数据库里存的是明文或者MD5,登录永远失败。Spring Security默认用DelegatingPasswordEncoder,会自动识别{bcrypt}...前缀。所以建用户时记得:

@Autowired
private PasswordEncoder passwordEncoder;

// 注册时
user.setPassword(passwordEncoder.encode(rawPassword));

我当时就忘了这茬,调试半小时才发现日志里默默报了Bad credentials——产品经理路过问“进度如何”,我只能强颜欢笑:“快了快了”。


权限控制:方法级注解才是生产力

接口级别的权限(比如/admin/**需要ADMIN角色)用authorizeHttpRequests()就能搞定。但更细粒度的控制,比如“只有订单创建者才能取消订单”,就得靠方法级安全注解了。

首先在配置类上加@EnableMethodSecurity(Spring Boot 3.x之后替代了旧的@EnableGlobalMethodSecurity):

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig { /* ... */ }

然后在Service方法上直接加注解:

@Service
public class OrderService {

    @PreAuthorize("@orderService.isOwner(#orderId, principal)")
    public void cancelOrder(Long orderId) {
        // 取消逻辑
    }

    public boolean isOwner(Long orderId, Authentication auth) {
        String currentUsername = auth.getName();
        return orderRepository.findById(orderId)
            .map(order -> order.getCreator().equals(currentUsername))
            .orElse(false);
    }
}

@PreAuthorize支持SpEL表达式,可以调用Bean方法、访问principal(当前用户)、甚至写复杂逻辑。这种灵活性比硬编码if-else优雅多了,也更容易单元测试。

不过要注意:方法级安全默认是基于代理的,如果同一个类里方法A调用带注解的方法B,权限检查不会生效!这是Spring AOP的经典陷阱。解决办法要么拆Service,要么用AopContext.currentProxy()——但后者很丑,建议重构。


生产环境避坑指南

1. JWT过期刷新机制

不要只发一个access token!一定要配refresh token,否则用户每小时重新登录,前端同事会提刀来找你。我们的做法是:

  • access token有效期15分钟;
  • refresh token有效期7天;
  • 刷新时校验refresh token合法性,并颁发新的access token。

2. 密码策略别偷懒

上线前务必确认:

  • 密码存储用BCrypt(强度10以上);
  • 登录失败次数限制(防暴力破解);
  • 敏感操作(如改密)需二次验证。

我们曾因没做失败锁定,被安全扫描工具标为高危漏洞。运维直接把报告甩到群里:“兄弟,这能上线?”

3. 日志与监控

AuthenticationFailureHandlerAuthenticationSuccessHandler里埋点,记录登录行为。结合ELK,能快速发现异常IP爆破。


性能与架构考量

Spring Security本身性能开销极低——核心是过滤器链,每个请求多几毫秒。但你的实现方式会影响性能

  • UserDetailsService.loadUserByUsername() 被频繁调用,务必加缓存(比如Caffeine或Redis);
  • 权限计算避免N+1查询,isOwner这类方法最好一次SQL查完;
  • JWT解析别每次读公钥,提前加载到内存。

另外,别把权限逻辑耦合到业务代码里。我们后来抽出了一个PermissionService,统一处理资源归属、角色校验,Controller层只调用checkCanEdit(userId, resourceId),清爽多了。


结语:安全不是功能,是底线

折腾两周后,系统顺利上线。合作方反馈“接口很规范”,领导夸“考虑周全”。其实我心里清楚:离真正的企业级安全还差得远——没做审计日志、没集成SSO、RBAC模型也简化了。但至少,我不再是那个随便写个拦截器就敢上线的愣头青了。

作为一个白天写Java、晚上刷申论的考公人,这次经历让我明白:技术深度和体制内追求的“稳妥”并不冲突。安全这件事,宁可多花三天,也不能留一分钟隐患。

对了,最近在刷《公共基础知识》,看到“网络安全法”章节时,莫名觉得亲切——毕竟,我也算是为网络空间清朗贡献过一行代码的人了(笑)。

如果你也在上海租房、加班、备考,不妨试试Spring Security。它可能不会帮你上岸,但至少能让你在岸上站得更稳一点。

评论 0

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