Spring Security 实战:两周内搞定公司认证系统
上个月底,我刚入职这家金融科技公司满两个月。之前五年一直在一家支付平台做后端,安全方面的要求已经刻进 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");
});
}
注意三点:
- 关掉 CSRF:JWT 无状态,CSRF Token 机制反而增加复杂度
- 设置 STATELESS:避免 Spring 创建 Session,节省内存
- 自定义异常处理:默认返回 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