Spring Security基础:快速搭建安全认证系统
本文写于2024年6月的一个深夜,咖啡已经凉了,但代码还在跑。别问,问就是被产品经理临时加需求逼的。
大家好,我是刚从英国某“水硕”毕业回国、目前在国内一家中型互联网公司摸爬滚打三年多的后端工程师。说实话,回国找工作那会儿,投了一堆简历石沉大海,最后还是靠自己博客里几篇性能优化的文章才拿到了现在这家公司的offer(所以写技术博客真的有用!)。最近在考虑换个环境,毕竟干了三年多,想试试新赛道——比如去搞搞高并发或者云原生方向。但在这之前,我得先把当前项目的技术债还一还,尤其是那个祖传的安全模块……
上周五晚上8点,我们组刚开完一个“敏捷冲刺”会议(其实就是产品经理又改需求了),运营同学突然在钉钉群里@我:“兄弟,下周上线的新活动页面要加登录校验,不然用户直接刷接口薅羊毛,财务又要哭晕在厕所。”我看了眼日历——距离上线只剩5天,而我们现有的认证体系还是基于JWT手写的简易版,连RBAC都没实现,更别说防CSRF、防重放攻击这些基本操作了。
那一刻,我真的想砸电脑。但转念一想:这不正是学Spring Security的好机会吗? 毕竟跳槽面大厂,Security几乎是必考题。于是,我咬咬牙,打开IDEA,开始重构我们的认证系统。
为什么是Spring Security?
先说清楚,我不是没考虑过其他方案。比如Go生态里的Gin + JWT中间件,写起来贼快,几行代码搞定。但问题是——我们整个后端都是Java栈,微服务架构、Spring Cloud全家桶都上了,硬塞一个Go服务进来,运维同学估计会拿拖把追着我打(他们上周还在吐槽K8s YAML文件太多,再加个语言栈怕是要原地爆炸)。
而且,Spring Security虽然学习曲线陡峭(官方文档能当枕头用),但它提供了企业级的安全控制能力:OAuth2、LDAP、SAML、Remember-Me、Session管理……你想要的它都有,你没想到的它也替你想好了。对于我们这种需要对接内部SSO、还要支持运营后台权限分级的场景,简直是量身定制。
当然,我也知道有些老哥会说:“Security太重了,不如自己写轻量级的。”拜托,你自己写的“轻量级”可能连Basic Auth都漏了Header校验,线上被扫一遍就裸奔了。安全这东西,宁可过度设计,也不能留漏洞——去年双11我们就是因为一个未授权访问漏洞,差点让黑产把优惠券刷光,CTO在复盘会上脸都绿了。
动手:从零搭建一个可用的认证系统
目标很明确:3天内完成重构,支持用户名密码登录、角色权限控制、且性能不能比原来差。
第一步:依赖与基础配置
首先,在pom.xml里加上Security依赖(这里用的是Spring Boot 3.x,注意Jakarta EE 9的包名变更):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
然后创建一个SecurityConfig类,继承WebSecurityConfigurerAdapter——等等,停! 在Boot 3里这个类已经被移除了!我一开始没注意版本,直接照着网上老教程写,结果启动报错:
java.lang.ClassNotFoundException: org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
当时真的想骂人。后来查了官方迁移指南才知道,现在要用SecurityFilterChain Bean来配置。踩坑记录+1。
正确姿势如下:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable() // 前后端分离项目可暂时关闭,但生产环境建议开启并配合前端token
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.httpBasic(); // 先用Basic Auth测试,后面换成JWT
return http.build();
}
}
注意这里用了Lambda DSL,比以前的链式调用清爽多了。.csrf().disable()是因为我们是纯API服务,前端用Axios发请求,CSRF Token不好处理。但如果你做的是传统Web应用(比如Thymeleaf渲染),千万别关!
第二步:自定义UserDetailsService
默认的InMemoryUserDetailsManager肯定不能上生产。我们需要对接数据库。
先建一张用户表(简化版):
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| username | VARCHAR(50) | 唯一登录名 |
| password | VARCHAR(100) | BCrypt加密后的密码 |
| enabled | TINYINT | 是否启用(1/0) |
| role | VARCHAR(20) | 角色,如USER, ADMIN |
然后实现UserDetailsService:
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("User not found: " + username);
}
// 注意:role字段存的是"ADMIN",但Spring Security要求前缀"ROLE_"
return User.builder()
.username(user.getUsername())
.password(user.getPassword())
.roles(user.getRole()) // 内部会自动加"ROLE_"前缀
.disabled(!user.isEnabled())
.build();
}
}
重点来了:数据库里存的角色是ADMIN,但Security在检查权限时会自动加上ROLE_前缀,变成ROLE_ADMIN。所以你在@PreAuthorize("hasRole('ADMIN')")里写的还是ADMIN,不用写全称。这个细节坑了不少人,包括我。
第三步:登录接口与JWT集成
Security默认只支持Form Login和HTTP Basic,但我们要的是JSON格式的登录响应。所以得自定义一个/api/auth/login接口:
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
try {
Authentication authenticate = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
// 生成JWT
String token = jwtTokenProvider.generateToken(authenticate);
return ResponseEntity.ok(new JwtResponse(token));
} catch (BadCredentialsException e) {
return ResponseEntity.status(401).body("Invalid credentials");
}
}
}
这里的关键是注入AuthenticationManager,手动触发认证流程。如果成功,就用自研的JwtTokenProvider生成Token(这部分代码略,核心是用jjwt库签发HS512签名的JWT)。
性能提示:JWT的payload里不要塞太多用户信息!我们之前有个同事把用户头像Base64编码塞进去,结果Token长达2KB,每次请求都带,Nginx日志直接爆掉。后来改成只存userId和role,前端需要详情再去查/user/info接口。
运维与监控:别等线上炸了才后悔
系统上线前,我和运维大哥一起review了几个关键点:
- 密码加密必须用BCrypt:绝对不要用MD5或SHA1!我们数据库里所有密码都用
BCryptPasswordEncoder重新加密了一遍。 - 登录失败锁定机制:防止暴力破解。用Redis记录失败次数,5次失败后锁定15分钟。
- 日志审计:所有认证成功/失败的事件都要记录到ELK,方便事后排查。比如:
@EventListener public void onAuthenticationSuccess(AuthenticationSuccessEvent event) { log.info("Login success: {}", event.getAuthentication().getName()); } - 压力测试:用JMeter模拟1000并发登录,QPS稳定在800+,比原来的纯JWT方案慢了约15%,但在可接受范围内(毕竟安全换来的)。
运维小哥还特意叮嘱:“别在Security里写数据库查询!” 因为每个请求都会经过Filter Chain,如果在loadUserByUsername里搞复杂联表,TPS直接腰斩。所以我们把用户角色缓存到了Redis,TTL 10分钟,兼顾性能与实时性。
总结:值得投入的“重型武器”
折腾了三天,终于在deadline前把新认证系统上线了。运营同学反馈:“现在黑产刷不了了,老板请我喝奶茶!” 虽然有点夸张,但至少没再收到告警邮件。
回头想想,Spring Security确实重,配置也啰嗦,但它的完备性和扩展性在企业级应用中无可替代。比起自己造轮子,用它就像穿上了一套铠甲——笨重,但保命。
至于Go?等我跳槽去了新公司,说不定真会用Gin+Casbin搞一套轻量级网关认证。但现在嘛,先把手里的Java项目稳住再说。毕竟,在国内互联网圈,能跑通的代码才是好代码,管它是不是“重型武器”呢。
最后送大家一句我在伦敦读书时导师常说的话:“Security is not a feature, it’s a requirement.” —— 安全不是锦上添花的功能,而是地基。别等到房子塌了才想起加固。
(完)
附:避坑清单
- ✅ Boot 3.x 用
SecurityFilterChain,别用废弃的WebSecurityConfigurerAdapter - ✅ 数据库存role时不用加
ROLE_前缀,Security会自动处理 - ✅ JWT payload别塞大对象,网络传输成本很高
- ✅ 所有认证逻辑避免DB查询,用缓存兜底
- ❌ 别在生产环境随意关闭CSRF(除非你是纯API)
- ❌ 密码绝对不要明文存储,BCrypt是底线
希望这篇带血泪的经验总结能帮到你。如果觉得有用,欢迎关注我的博客(虽然更新很佛系)。下次见!

评论 0