Spring Security 实战:两周内搞定公司认证系统

GC观察员
2026-01-03 18:41
阅读 1590

上个月底,我刚入职这家金融科技公司满两个月。之前五年一直在一家支付平台做后端,安全方面的要求已经刻进 DNA 了——毕竟谁也不想因为一个越权漏洞被监管找上门。新公司节奏快得离谱,产品经理上周五下午三点甩过来一个需求:“下周一上线用户登录功能,要支持多角色、权限隔离,还得防住常见攻击。”我当场就懵了:这不就是让我用周末两天搭一套完整的认证授权体系?

还好,Spring Security 这个老朋友还在。虽然最近沉迷 Rust 的内存安全模型(真香!),但 Java 生态依然是金融系统的主力。边听 Lo-fi beats 边敲代码,总算在 deadline 前把系统跑起来了。今天就来聊聊这次实战中的血泪经验。

被逼出来的安全架构

先说背景。我们新项目是个面向企业客户的资产管理系统,涉及敏感财务数据。老板放话:“安全问题一票否决。”所以一开始我就排除了手写拦截器这种土办法——不是不能做,而是不敢赌。Spring Security 虽然配置有点“魔法”,但社区成熟、审计方便,出了问题也容易甩锅(划掉)溯源。

核心需求其实很经典:

  • 用户密码登录 + JWT Token 认证
  • 三类角色:普通员工、部门主管、超级管理员
  • 接口级权限控制(比如主管只能看本部门数据)
  • 防暴力破解、防 CSRF、防 Session Fixation

听起来是不是像教科书案例?但现实是,测试同事提的第一个 Bug 就让我冷汗直流:用低权限账号调高权限接口,居然返回 200!后来发现是 @PreAuthorize 注解没生效——因为忘了在启动类加 @EnableGlobalMethodSecurity(prePostEnabled = true)。这种低级错误,在压力大的时候真的会犯。

配置陷阱:别被默认行为坑了

Spring Security 的自动配置既是福音也是诅咒。它默认开启了一堆安全特性,但如果你不知道细节,反而会埋雷。

密码加密:BCrypt 是底线

很多教程还在用 {noop} 明文存储密码,这在金融系统里等于自杀。我直接上了 BCrypt:

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(12); // 强度调到12,慢点但更安全
}

这里有个坑:BCrypt 每次加密结果都不同,所以绝对不能password.equals(storedHash) 来校验!必须通过 passwordEncoder.matches(rawPassword, encodedPassword)。我见过实习生这么干,差点被运维大哥追着打。

登录流程:自定义入口点

默认的表单登录对 API 不友好。我们改成 JSON 请求:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .csrf().disable() // 前后端分离项目,用 JWT 就关掉 CSRF
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and()
        .authorizeRequests()
            .antMatchers("/auth/login").permitAll()
            .anyRequest().authenticated()
        .and()
        .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
        .exceptionHandling()
            .authenticationEntryPoint((request, response, authException) -> {
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
                response.getWriter().write("Invalid token or not logged in");
            });
}

注意三点:

  1. 关掉 CSRF:JWT 无状态,CSRF Token 机制反而增加复杂度
  2. 设置 STATELESS:避免 Spring 创建 Session,节省内存
  3. 自定义异常处理:默认返回 302 重定向,API 客户端根本看不懂

权限粒度:方法级 vs URL 级

早期我尝试用 .antMatchers("/api/admin/**").hasRole("ADMIN") 这种 URL 匹配,但很快发现问题:如果接口路径变动(比如 /admin/users 改成 /v2/admin/users),权限配置就失效了。而且无法实现“数据级权限”(如“只能删自己的文章”)。

果断切换到方法级注解:

@RestController
public class AssetController {
    
    @PreAuthorize("hasRole('MANAGER') and #departmentId == authentication.principal.departmentId")
    @DeleteMapping("/assets/{id}")
    public ResponseEntity<Void> deleteAsset(@PathVariable Long id, 
                                          @RequestParam Long departmentId) {
        // 业务逻辑
    }
}

这里用 SpEL 表达式结合了角色和数据属性。虽然性能略低于 URL 匹配(反射开销),但灵活度碾压。金融系统宁可慢 1ms,也不能错放权限。

生产环境踩坑实录

本地跑得好好的,一上测试环境就翻车。分享几个真实事故:

坑 1:JWT 过期时间太短

初始设的 30 分钟过期,结果测试同事抱怨“点两下页面就要重新登录”。后来改成:

  • Access Token:2 小时(配合前端静默刷新)
  • Refresh Token:7 天(存数据库,可主动吊销)

关键代码:

// 生成 Token 时
Date accessExpiration = new Date(System.currentTimeMillis() + 2 * 3600 * 1000);
Date refreshExpiration = new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000);

// 刷新逻辑
if (isRefreshToken(token) && !isTokenExpired(token)) {
    String newAccessToken = generateAccessToken(userDetails);
    saveRefreshTokenToDb(newAccessToken, userDetails); // 更新 DB 中的关联关系
}

坑 2:并发登录没限制

安全审计要求“同一账号只能一处登录”。Spring Security 默认不限制,我通过自定义 ConcurrentSessionControlStrategy 解决:

@Bean
public SessionRegistry sessionRegistry() {
    return new SessionRegistryImpl();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .sessionManagement()
            .maximumSessions(1)
            .sessionRegistry(sessionRegistry())
            .expiredSessionStrategy((request, response, sessionInformation) -> {
                // 返回 JSON 提示“账号已在别处登录”
                response.setContentType("application/json;charset=UTF-8");
                response.getWriter().write("{\"code\":401,\"msg\":\"CONCURRENT_LOGIN\"}");
            });
}

但注意:无状态 JWT 无法直接用这个方案!因为 SessionRegistry 依赖服务器端 Session。最终我们改用 Redis 记录 Token 黑名单:用户登出或新登录时,把旧 Token 加入黑名单并设置 TTL = 原过期时间。

坑 3:密码错误次数没限制

OWASP 十大漏洞第一条就是“暴力破解”。Spring Security 不提供内置防护,得自己加:

@Service
public class LoginAttemptService {
    private final RedisTemplate<String, Integer> redisTemplate;
    
    public void loginFailed(String username) {
        String key = "login_attempts:" + username;
        Integer attempts = redisTemplate.opsForValue().get(key);
        if (attempts == null) {
            redisTemplate.opsForValue().set(key, 1, Duration.ofMinutes(15));
        } else if (attempts < 5) {
            redisTemplate.opsForValue().increment(key);
        } else {
            throw new LockedException("Too many attempts");
        }
    }
    
    public void loginSucceeded(String username) {
        redisTemplate.delete("login_attempts:" + username);
    }
}

在认证过滤器里调用它。简单有效,15 分钟后自动解锁,避免误伤。

性能与监控:金融系统的命门

安全不能以牺牲性能为代价。我们做了三件事:

1. 缓存 UserDetails

每次请求都查数据库加载用户信息?达咩!用 Caffeine 本地缓存:

@Cacheable(value = "userDetails", key = "#username")
public UserDetails loadUserByUsername(String username) {
    // 查询 DB + 组装权限
}

缓存 10 分钟,权限变更时主动清除。QPS 从 1200 提升到 3500+。

2. 关键操作留痕

所有权限变更、登录登出写审计日志:

@AfterReturning("execution(* com.example.security.AuthService.login(..))")
public void logLoginSuccess(JoinPoint joinPoint) {
    String username = (String) joinPoint.getArgs()[0];
    auditLog.info("LOGIN_SUCCESS|user={}|ip={}", username, getClientIP());
}

日志格式统一,方便 SIEM 系统采集。上次排查异常登录,全靠这些记录。

3. 压测验证

上线前用 JMeter 模拟 5000 并发登录:

  • CPU 使用率 < 40%
  • P99 延迟 < 200ms
  • 错误率 = 0

特别检查了 Token 刷新风暴场景——当大量 Token 同时过期时,系统会不会雪崩。结论:只要 Redis 和 DB 连接池配置合理,稳如老狗。

开发心得:安全不是功能,是习惯

折腾两周下来,最大的感悟是:安全不是某个模块,而是贯穿整个开发流程的肌肉记忆

  • 写接口时,第一反应是“谁有权调这个?”
  • 设计表结构时,自动加上 created_by 字段用于数据隔离
  • Code Review 重点看权限校验是否遗漏
  • 甚至和前端约定:敏感操作必须二次确认(比如删除)

另外,别迷信框架。Spring Security 再强大,也挡不住你把 @PreAuthorize("hasRole('USER')") 错写成 hasRole('ADMIN')。自动化测试必须覆盖权限场景:

@Test
@WithMockUser(roles = "USER")
void deleteUserAsNormalUser_ShouldDeny() {
    mockMvc.perform(delete("/users/123"))
           .andExpect(status().isForbidden()); // 而不是 isUnauthorized!
}

注意区分 403(权限不足)和 401(未认证),很多前端同学搞混,导致用户体验割裂。

最后:为什么我还是爱 Java

虽然最近在研究 Rust(零成本抽象 + 内存安全真香),但在企业级应用领域,Java + Spring Security 的组合依然是性价比之王。成熟的生态、丰富的工具链、清晰的升级路径——这些在金融行业比“炫技”重要一万倍。

当然,如果哪天 Spring Security 能像 Rust 的 borrow checker 一样,在编译期就拦住权限漏洞,那我就彻底转 Rust 了(笑)。

总之,安全系统没有银弹。理解原理、敬畏风险、持续验证,才是金融后端工程师的生存之道。下次再遇到“周一上线”的需求,至少我知道该从哪下手了。

(完)


附:关键配置速查表

配置项 推荐值 说明
BCrypt 强度 10-12 >12 可能影响登录性能
Access Token 有效期 1-2 小时 平衡安全与体验
Refresh Token 有效期 3-7 天 需支持主动吊销
登录失败锁定阈值 5 次 防暴力破解
锁定时间 15 分钟 避免 DoS 攻击
UserDetails 缓存 TTL 10 分钟 权限变更需主动清除

希望这篇带血泪的经验能帮到你。如果你们公司也在搞安全认证,欢迎评论区交流——或者直接甩个 JD 过来?(Rust 岗优先 😎)

评论 0

最热最新
暂无评论
GC观察员Lv.1
0
影响力
0
文章
0
粉丝