Spring Security 实战:从 Rob Winch 的安全问答中提炼关键实践

2026-06-11 33 预计阅读时间: 1 分钟
来源: spring.io AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:8 分钟

Spring Security 项目负责人 Rob Winch 在这期 Bootiful Podcast 中回答了一系列关于应用安全的常见问题。他长期主导 Spring Security 的演进,对开发者最容易踩的坑、最该优先做的事有非常清晰的判断。下面把核心观点拆开,配上可直接运行的代码示例。

认证与授权:别把两者混在一起

Rob 反复强调的一点——很多团队把"谁登录了"和"能做什么"揉在一块处理,导致权限逻辑散落在 Controller、Service 各处,改一处就怕漏一处。

Spring Security 的设计本身就是分层的:

  • Authentication:确认身份,拿到 Principal
  • Authorization:基于身份(或其他上下文)决定是否放行

正确做法是让认证逻辑集中在 Filter / Provider 层,授权逻辑用 @PreAuthorizeSecurityConfig 里的 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 提供了 @WithMockUserWebTestClient 集成,专门解决这个问题:

@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 的核心建议可以总结为一句话:别急着覆盖默认值,先搞清楚默认值在保护什么,再决定要不要关掉它。


相关推荐