快速上手Spring Security:别再裸奔你的API了
上周五晚上九点半,我正蹲在实验室角落啃着冷掉的黄焖鸡,盯着屏幕上一行刺眼的日志:
2024-06-14 21:18:32 ERROR o.s.s.w.a.ExceptionTranslationFilter - Access is denied
不是因为代码写错了,而是——我们的内部管理后台居然没做任何认证!产品经理昨天刚提了个需求:“加个登录页吧,现在谁都能进后台删数据。” 我差点一口饭喷出来。这都研二了,在实验室干了快两年,天天研究分布式一致性、看Raft论文、扒开源项目的源码,结果自家项目连基础安全都没做?说出去怕是要被GitHub上那些star过万的项目作者笑死。
更讽刺的是,隔壁组搞Python微服务的同学前几天还在群里炫耀他们用FastAPI+OAuth2三行代码搞定鉴权。而我们这个Spring Boot项目,愣是裸奔了半年多,靠“内网IP白名单”这种祖传防御机制苟活。运维大哥听说后直接发了个“😅”表情包,懂的都懂。
行吧,既然躲不过,那就干。这篇就记录下我如何用Spring Security快速搭起一个能上线的安全认证系统。不整那些花里胡哨的理论,全是实打实能跑起来的代码和踩过的坑。
为什么偏偏是Spring Security?
我知道,一提到Spring Security,很多人的第一反应是:“配置地狱”、“XML噩梦”、“文档比代码还难懂”。说实话,我一开始也这么想。但架不住它是Spring生态的亲儿子啊。咱们实验室的项目从Spring Boot 2.3一路升到3.2,Security的starter集成得那叫一个丝滑。而且,它真的能扛住生产环境的压力。
去年双11期间,我们给学校某个区块链溯源平台做后端支持(没错,就是那个号称要用区块链解决农产品信任问题的项目),QPS峰值冲到3000+。当时临时加的Security模块,在配合Redis做Session共享后,延迟只增加了不到5ms。对比之下,某些轻量级框架在高并发下直接崩了线程池。
所以,别被它的复杂外表吓到。只要抓住核心概念,快速搭建完全没问题。
核心思路:认证 + 授权 = 安全
Spring Security的本质就两件事:
- 你是谁?(Authentication)
- 你能干啥?(Authorization)
我们这次的目标很简单:
- 用户通过用户名/密码登录
- 登录后获得JWT Token
- 后续请求携带Token才能访问受保护的API
至于OAuth2、LDAP、多因子认证?以后再说。先让系统别裸奔!
动手:三步走战略
第一步:加依赖,别手抖
pom.xml里加上这些(注意版本兼容性,我们用的是Spring Boot 3.2 + Java 17):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
💡 提醒:别直接抄网上那些过时的教程!Spring Boot 3.x已经移除了
javax.*包,全部换成jakarta.*了。我第一次跑的时候ClassNotFoundException直接给我整懵了。
第二步:用户信息从哪来?数据库设计很关键
我们用最朴素的方案:MySQL存用户。表结构如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| username | VARCHAR(50) | 唯一,登录用 |
| password | VARCHAR(100) | 必须加密存储! |
| role | VARCHAR(20) | 角色,如ADMIN, USER |
重点来了:密码绝对不能明文存! 我们用BCryptPasswordEncoder:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
注册时这样存:
// UserService.java
public void register(String username, String rawPassword) {
String encodedPassword = passwordEncoder.encode(rawPassword);
userRepository.save(new User(username, encodedPassword, "USER"));
}
曾经有个实习生图省事,把密码MD5后存进去,结果被我当场抓包。现在他每次见我都绕道走 😏
第三步:配置Security,别被过滤器链搞晕
这是最容易翻车的地方。Spring Security背后是一堆Filter,顺序错了直接403。我们的目标是:
/api/auth/login允许匿名访问- 其他
/api/**都要带有效Token - 静态资源(比如Swagger UI)放行
配置类长这样:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // 前后端分离,关掉CSRF
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/login").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}
}
看到那个.addFilterBefore()了吗?这就是自定义的JWT校验过滤器,它会在标准的用户名密码认证之前执行。如果Token有效,就直接放行;否则交给后面的流程处理。
JWT工具类:自己造轮子还是用现成的?
虽然网上有无数JWT工具类,但我还是建议自己写一个简单的,至少知道原理。毕竟我们不是在搞金融级安全,没必要过度设计。
@Component
public class JwtUtil {
private final String SECRET_KEY = "your-very-long-secret-key-here"; // 实际要用环境变量!
private final long EXPIRATION = 86400000; // 24小时
public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
.signWith(SignatureAlgorithm.HS512, SECRET_KEY)
.compact();
}
public String getUsernameFromToken(String token) {
return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody().getSubject();
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
return true;
} catch (Exception e) {
return false;
}
}
}
⚠️ 注意:
SECRET_KEY千万别写死在代码里!我们实验室的做法是用K8s Secret挂载,或者从Vault读取。本地开发可以用.env文件,配合@Value("${jwt.secret}")注入。
自定义JWT过滤器:核心逻辑在这
这个过滤器会拦截所有请求,检查Header里的Authorization: Bearer <token>:
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
String username = null;
String jwtToken = null;
if (header != null && header.startsWith("Bearer ")) {
jwtToken = header.substring(7);
try {
username = jwtUtil.getUsernameFromToken(jwtToken);
} catch (Exception e) {
logger.error("JWT解析失败: {}", e.getMessage());
}
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtUtil.validateToken(jwtToken)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
chain.doFilter(request, response);
}
}
这段代码干了三件事:
- 从Header提取Token
- 解析出用户名
- 如果Token有效,就把用户信息塞进Spring Security上下文
之后在Controller里,你就能用@AuthenticationPrincipal直接拿到当前用户:
@GetMapping("/profile")
public ResponseEntity<User> getProfile(@AuthenticationPrincipal UserDetails userDetails) {
return ResponseEntity.ok(userService.findByUsername(userDetails.getUsername()));
}
登录接口:别忘了返回Token
最后补上登录入口:
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtUtil jwtUtil;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(), request.getPassword())
);
} catch (BadCredentialsException e) {
return ResponseEntity.status(401).body("用户名或密码错误");
}
UserDetails userDetails = userDetailsService.loadUserByUsername(request.getUsername());
String token = jwtUtil.generateToken(userDetails);
return ResponseEntity.ok(new JwtResponse(token));
}
}
这里用到了AuthenticationManager,它会调用我们之前配置的UserDetailsService去查数据库验证密码。整个流程闭环了。
踩坑实录:那些让我想砸键盘的瞬间
CORS跨域问题
前端Vue项目跑在localhost:8080,后端在8081,登录时浏览器报CORS error。解决方案:在SecurityConfig里加上:http.cors(cors -> cors.configurationSource(request -> { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(Arrays.asList("http://localhost:8080")); config.setAllowedMethods(Arrays.asList("*")); config.setAllowedHeaders(Arrays.asList("*")); return config; }));Swagger被拦了
本地调试时Swagger UI打不开,因为Security默认拦截所有路径。记得在authorizeHttpRequests里放行Swagger相关路径。密码加密不一致
测试账号是我手动插进数据库的,但忘了用BCrypt加密,导致一直认证失败。后来写了个初始化脚本自动加密测试数据。
和Python、区块链、GitHub有啥关系?
你可能会问:标题里提了Python、区块链、GitHub,结果正文没见着?别急,这恰恰是我想说的。
Python:我们组有个Python服务负责区块链数据上链,它需要调用我们Java服务的API。有了统一的JWT认证后,Python那边只需要在请求头加Token就行,不用再搞一套独立的鉴权逻辑。多语言协作的痛,谁用谁知道。
区块链:那个溯源平台要求所有操作可审计。我们在Security的
AuthenticationSuccessHandler里加了日志,记录谁在什么时候登录了系统,后续可以写入区块链作为操作证明。安全认证成了可信数据流的第一环。GitHub:整个Security模块的代码,我参考了spring-projects/spring-security官方仓库的samples。开源的力量啊!顺手给文档提了个PR,修正了一个过时的配置示例,现在已经被merged了 🎉
总结:安全不是功能,是底线
折腾完这一套,我们的管理后台终于不再是“公开的秘密”。从开始到上线,实际编码时间不到一天(当然,前期调研和踩坑花了三天)。Spring Security虽然学习曲线陡峭,但一旦掌握,你会发现它提供了极其灵活的安全模型。
最后送大家一句我们导师常说的话:“你可以没有高性能,但不能没有安全性。” 尤其在涉及用户数据的系统里,裸奔API无异于把家门钥匙挂在小区公告栏。
对了,下周产品经理又要加“第三方微信登录”……我已经预感到又要和OAuth2搏斗了。不过没关系,先把基础打牢,后面的扩展才稳。
代码已上传到我们实验室的私有GitLab(因为涉及内部业务,就不放GitHub了),但核心逻辑和本文一致。如果你也在用Spring Boot,别等事故发生了才想起安全——现在就去加个Security吧!
P.S. 写完这篇文章,我发现自己的JWT_SECRET_KEY居然提交到Git历史里了……赶紧rebase重写了提交记录。程序员的自我修养,从撤销错误提交开始 😅

评论 0