Spring Security 项目负责人 Rob Winch 在这期 Bootiful Podcast 中回答了一系列关于应用安全的常见问题。他长期主导 Spring Security 的演进,对开发者最容易踩的坑、最该优先做的事有非常清晰的判断。下面把核心观点拆开,配上可直接运行的代码示例。
认证与授权:别把两者混在一起
Rob 反复强调的一点——很多团队把"谁登录了"和"能做什么"揉在一块处理,导致权限逻辑散落在 Controller、Service 各处,改一处就怕漏一处。
Spring Security 的设计本身就是分层的:
- Authentication:确认身份,拿到
Principal - Authorization:基于身份(或其他上下文)决定是否放行
正确做法是让认证逻辑集中在 Filter / Provider 层,授权逻辑用 @PreAuthorize 或 SecurityConfig 里的 URL 规则统一声明,不要在业务代码里手动 if (user.getRole() == "ADMIN")。
// 认证:自定义 AuthenticationProvider,集中处理身份验证
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
// 这里只做身份验证,不判断权限
UserDetails user = userDetailsService.loadUserByUsername(username);
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new BadCredentialsException("密码错误");
}
return new UsernamePasswordAuthenticationToken(
user, password, user.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class
.isAssignableFrom(authentication);
}
}
// 授权:用注解声明,与认证逻辑彻底分离
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/{id}")
public void deleteOrder(@PathVariable Long id) {
orderService.delete(id);
}
@PreAuthorize("hasAnyRole('USER', 'ADMIN')")
@GetMapping("/{id}")
public Order getOrder(@PathVariable Long id) {
return orderService.findById(id);
}
}
关键原则:认证归 Security 框架管,授权归声明式规则管,业务代码只管业务。
CSRF:别因为"我们只用 API"就关掉它
Rob 提到开发者最常见的误判之一——"前后端分离、纯 API 调用,CSRF 不需要了"。这个结论在浏览器场景下是危险的。
如果你的 API 可能被浏览器页面发起请求(哪怕是通过 <img>、<form> 等标签),CSRF 攻击就成立。只有以下情况才可以安全地禁用 CSRF:
- 纯非浏览器客户端(如服务间调用、CLI 工具)
- API 严格使用
Authorization: Bearer头传递 token,且 token 不存在 Cookie 里
如果你用的是 Cookie-based Session 认证,CSRF 必须开:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
// 只对 API 路径禁用,其他路径保留保护
.ignoringRequestMatchers("/api/public/**")
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
return http.build();
}
}
CookieCsrfTokenRepository.withHttpOnlyFalse() 让前端 JS 能读到 CSRF token,方便 SPA 在请求头里带上。Rob 特别提醒:Spring Security 6 默认把 CSRF token 放在 Cookie 里但延迟加载(LazyCsrfTokenRepository),首次请求不带 token,前端需要先发一个 GET 拿到 token 再发 POST。
方法级安全:开了 @EnableMethodSecurity 才生效
一个高频问题:为什么 @PreAuthorize 不起作用?答案是——你没开开关。
Spring Security 6 用 @EnableMethodSecurity 替代了旧的 @EnableGlobalMethodSecurity,默认启用基于 SpEL 的 @PreAuthorize / @PostAuthorize,不再需要额外声明 prePostEnabled = true:
@Configuration
@EnableMethodSecurity // Spring Security 6 的正确注解
public class MethodSecurityConfig {
// 不需要额外配置,默认就支持 @PreAuthorize
}
如果你还在用 Spring Security 5,对应写法是:
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig { }
Rob 建议:优先用方法级注解做细粒度授权,URL 级规则做粗粒度兜底。两者配合,漏网概率最低。
密码存储:PasswordEncoder 不是可选的
Rob 对"自己写加密逻辑"的团队态度很直接——不要自己写。用 Spring Security 提供的 PasswordEncoder,首选 BCryptPasswordEncoder:
@Bean
public PasswordEncoder passwordEncoder() {
// BCrypt 自带盐,强度参数默认 10,够用
return new BCryptPasswordEncoder();
}
注册用户时:
@Service
public class RegistrationService {
private final UserRepository userRepo;
private final PasswordEncoder passwordEncoder;
public RegistrationService(UserRepository userRepo,
PasswordEncoder passwordEncoder) {
this.userRepo = userRepo;
this.passwordEncoder = passwordEncoder;
}
public void register(String username, String rawPassword) {
// 只存编码后的密码,永远不存明文
String encoded = passwordEncoder.encode(rawPassword);
User user = new User(username, encoded, Role.USER);
userRepo.save(user);
}
}
验证时 Spring Security 自动调用 passwordEncoder.matches(),你不需要手动比对。
Rob 还提到一个升级策略:如果你数据库里存的是旧算法(如 SHA-256),可以用 DelegatingPasswordEncoder 做平滑迁移——新用户用 BCrypt,老用户登录时自动重新编码存储:
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
编码结果会带前缀标识算法,如 {bcrypt}$2a$10$...,老格式 {sha256}... 登录成功后自动升级为 {bcrypt}。
安全测试:别只测 Happy Path
Rob 指出,多数团队的安全测试只验证"正常登录能访问",不验证"未登录应该被拦截"。Spring Security 提供了 @WithMockUser 和 WebTestClient 集成,专门解决这个问题:
@SpringBootTest
@AutoConfigureMockMvc
class SecurityTests {
@Autowired
private MockMvc mockMvc;
@Test
void unauthenticatedRequestShouldBeRejected() throws Exception {
mockMvc.perform(delete("/api/orders/1"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void userCannotDeleteOrder() throws Exception {
mockMvc.perform(delete("/api/orders/1"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void adminCanDeleteOrder() throws Exception {
mockMvc.perform(delete("/api/orders/1"))
.andExpect(status().isNoContent());
}
}
三个测试覆盖了三条核心路径:未认证 → 401,权限不足 → 403,权限匹配 → 成功。这才是安全测试的基本覆盖度。
实践清单
把 Rob 的建议浓缩成可执行的检查项:
| 检查项 | 状态 |
|---|---|
| 认证逻辑集中在 Provider/Filter,不在 Controller 里 | ☐ |
授权用 @PreAuthorize 声明,不散落在业务代码 |
☐ |
| CSRF:Cookie-Session 场景下必须开启 | ☐ |
@EnableMethodSecurity 已启用 |
☐ |
密码只用 PasswordEncoder,不手写加密 |
☐ |
老密码用 DelegatingPasswordEncoder 迁移 |
☐ |
| 安全测试覆盖 401 / 403 / 成功三条路径 | ☐ |
Spring Security 的默认配置在 6.x 已经大幅加固——默认拒绝所有请求、默认启用 CSRF、默认重定向到登录页。Rob 的核心建议可以总结为一句话:别急着覆盖默认值,先搞清楚默认值在保护什么,再决定要不要关掉它。