最佳实践
定义与作用
Spring Security 最佳实践是一套面向生产环境的配置准则与检查清单,旨在通过纵深防御(Defense in Depth)策略,将安全漏洞的发生概率和攻击影响面降至最低。它不局限于框架本身的 API 使用,而是涵盖密码策略、传输层安全、权限最小化、日志审计、安全响应头五大维度,确保应用在部署到生产环境后具备抵御常见 Web 攻击(如凭证泄露、中间人攻击、权限提升、会话劫持、点击劫持)的能力。
本清单的核心原则是:框架默认配置已经提供了良好的基础,但生产环境必须根据业务场景进行显式加固,绝不可依赖默认值。
核心原理
生产级安全配置遵循 "最小权限 + 显式声明 + 纵深防御" 三位一体的设计思想:
- 密码策略:使用自适应哈希算法(BCrypt/Argon2)存储密码,禁止明文和可逆加密,通过
DelegatingPasswordEncoder支持平滑算法迁移; - 传输层安全:强制 HTTPS(HSTS),禁止敏感流量在明文通道传输,防止中间人攻击和会话 Cookie 窃取;
- 最小权限:URL 授权与方法级别授权双重校验,角色层级设计避免权限膨胀,所有未明确放行的请求默认拒绝;
- 日志审计:记录认证失败、授权拒绝、异常访问模式,保留足够字段(时间戳、源 IP、目标资源、操作结果)用于事后溯源;
- 安全响应头:通过
HeaderWriterFilter写入X-Frame-Options、Content-Security-Policy、Strict-Transport-Security等头,从浏览器层削弱 XSS、Clickjacking、MIME 嗅探等攻击面。
生产级安全配置检查清单图
示例一:从弱密码策略到生产级 BCrypt 升级
场景说明
项目在开发阶段为简化调试使用 {noop} 明文存储密码,计划上线前必须升级为生产级密码策略。目标是:① 新密码使用 BCrypt 编码;② 存量明文密码平滑迁移,不影响已有用户登录;③ 禁止任何配置回退到明文。
操作前配置(开发阶段,不可用于生产)
@Configuration
@EnableWebSecurity
public class DevSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("alice")
.password("{noop}alice123") // 明文,仅开发调试
.roles("USER")
.and()
.withUser("bob")
.password("{noop}bob123")
.roles("ADMIN");
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance(); // 严重安全隐患
}
}
操作后配置(生产级)
@Configuration
@EnableWebSecurity
public class ProdSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(auth -> auth
.antMatchers("/public/**").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().denyAll() // 未明确匹配的路径默认拒绝
)
.formLogin(Customizer.withDefaults())
.csrf().and()
.sessionManagement(session -> session
.sessionFixation().migrateSession()
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("alice")
.password("{bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMy.MqrjE5z1LkV5yXJ3yX7QZ5J3QZ5J3QZ") // 已迁移的密文
.roles("USER")
.and()
.withUser("bob")
.password("{bcrypt}$2a$10$X5J3QZ5J3QZ5J3QZ5J3QZ5J3QZ5J3QZ5J3QZ5J3QZ5J3QZ5J3QZ5J3")
.roles("ADMIN");
}
@Bean
public PasswordEncoder passwordEncoder() {
// 默认使用 BCrypt 编码新密码,同时支持 {noop} {bcrypt} {pbkdf2} 前缀验证旧密码
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
结果分析
PasswordEncoderFactories.createDelegatingPasswordEncoder()返回的DelegatingPasswordEncoder默认使用BCryptPasswordEncoder编码新密码,同时通过密码前缀识别存量密码的编码方式。前缀为{noop}的明文密码仍能被验证,但新注册用户的密码将以{bcrypt}前缀存储。- 在生产上线前,应通过脚本将数据库中所有
{noop}前缀的密码强制要求用户重置,或在用户首次登录时通过UserDetailsPasswordService自动升级为 BCrypt。绝对禁止在application.properties或配置类中保留NoOpPasswordEncoder的 Bean 定义。 anyRequest().denyAll()是安全配置的兜底原则:任何未被显式匹配规则覆盖的请求,默认拒绝访问,防止因路径遗漏导致未授权访问。
示例二:安全响应头从零散配置到统一策略
场景说明
应用最初未显式配置安全响应头,依赖 Spring Security 的默认值。安全审计发现:① 需要更严格的 CSP 策略防止 XSS 加载外部脚本;② 需要允许同域 iframe 嵌入但禁止跨域嵌套;③ 需要 HSTS 强制浏览器始终使用 HTTPS 访问。
操作前配置(默认/松散)
@Configuration
@EnableWebSecurity
public class LooseHeaderConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(auth -> auth
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.headers(); // 默认仅写入 X-Frame-Options: DENY 等基础头,CSP 未配置
}
}
操作后配置(生产级安全头策略)
@Configuration
@EnableWebSecurity
public class StrictHeaderConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(auth -> auth
.antMatchers("/public/**").permitAll()
.antMatchers("/api/**").authenticated()
.anyRequest().denyAll()
)
.formLogin(Customizer.withDefaults())
.csrf().and()
.headers(headers -> headers
.frameOptions(frame -> frame.sameOrigin()) // 仅允许同源 iframe 嵌入
.contentTypeOptions() // 启用 X-Content-Type-Options: nosniff
.xssProtection(xss -> xss.disable()) // 禁用旧版 X-XSS-Protection(现代浏览器依赖 CSP)
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self' https://cdn.example.com; " +
"style-src 'self' https://cdn.example.com 'unsafe-inline'; " +
"img-src 'self' data: https://cdn.example.com; " +
"frame-ancestors 'self'; " +
"base-uri 'self'; " +
"form-action 'self'"
)
)
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000) // 1 年
.preload(true)
)
.cacheControl(cache -> cache.disable()) // 或根据业务定制,禁止缓存敏感页面
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation().migrateSession()
);
}
}
结果分析
X-Frame-Options: SAMEORIGIN替代默认的DENY,允许同域 iframe 嵌入(适用于需要内嵌自身页面的场景),同时阻止跨域 Clickjacking 攻击。Content-Security-Policy通过default-src 'self'将默认资源加载限制在同源,通过script-src和style-src白名单控制外部 CDN。frame-ancestors 'self'是 CSP 层面对 iframe 嵌套的二次防护,与X-Frame-Options形成互补。Strict-Transport-Security头告知浏览器在接下来 1 年内始终通过 HTTPS 访问当前域名及其子域,防止 SSL 剥离攻击。preload(true)表示申请加入浏览器厂商的 HSTS 预加载列表。- 默认的
X-XSS-Protection: 1; mode=block已被现代浏览器废弃,且可能引入新的副作用(如阻止合法响应)。推荐显式禁用,将 XSS 防御完全交给 CSP 和输入输出编码。
易错场景:生产环境禁用 CSRF 与开放端点的双重陷阱
现象
开发者为 "简化前后端分离项目的对接",在配置中做了如下两个改动:
@Configuration
@EnableWebSecurity
public class DangerousConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(auth -> auth
.antMatchers("/api/**").permitAll() // 陷阱一:开放所有 API 路径
.anyRequest().authenticated()
)
.csrf().disable() // 陷阱二:全局禁用 CSRF
.formLogin(Customizer.withDefaults());
}
}
根因分析
陷阱一:/api/** 的 permitAll() 过于宽泛。若 /api/users 是修改用户信息的接口,攻击者无需任何认证即可调用。正确的做法是:即便公开 API,也应区分读操作与写操作,写操作必须要求认证或 API Token。
// 错误的宽泛授权
.antMatchers("/api/**").permitAll()
// 正确的精细化授权
.antMatchers(HttpMethod.GET, "/api/public/**").permitAll()
.antMatchers(HttpMethod.POST, "/api/orders/**").hasRole("USER")
.antMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")
.anyRequest().denyAll()
陷阱二:全局禁用 CSRF。在 Spring Security 5.x 中,csrf().disable() 应在极小范围内使用,例如纯无状态 JWT API(通过 Bearer Token 认证,Cookie 不参与认证)。如果应用中仍存在基于 Session 的表单登录或 Cookie 认证,全局禁用 CSRF 意味着任何恶意网站都可以诱导已登录用户发起 POST/PUT/DELETE 请求。
// 错误:全局禁用
http.csrf().disable();
// 正确:仅对特定路径禁用,其余路径保持防护
http.csrf(csrf -> csrf
.ignoringAntMatchers("/api/jwt/**") // 无状态 JWT 路径忽略 CSRF
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
// SPA 前端从 Cookie 读取 Token 并写入 Header
);
正确做法
生产环境中的安全配置必须遵循 "默认拒绝,最小开放" 原则:
- 每新增一条
antMatchers(...).permitAll()都必须经过安全评审,确认该路径下不存在任何敏感操作; - 只有在完全无状态、不依赖 Cookie 或 Session 的 API 中,才考虑忽略 CSRF,且应使用
ignoringAntMatchers做局部排除而非全局禁用; - 写操作(POST / PUT / DELETE / PATCH)必须关联认证与授权规则,禁止默认放行。
面试考点
Q:生产环境中为什么推荐使用 DelegatingPasswordEncoder 而不是直接使用 BCryptPasswordEncoder?
BCryptPasswordEncoder只能处理 BCrypt 格式的密码。实际生产环境常面临算法迁移场景:早期系统可能使用 SHA-256、PBKDF2 或更老的算法存储密码,直接替换编码器会导致所有存量用户无法登录。DelegatingPasswordEncoder通过密码前缀{bcrypt}、{pbkdf2}、{sha256}识别编码算法,验证时自动选择对应的PasswordEncoder,而编码新密码时默认使用 BCrypt。这实现了 "平滑迁移":存量密码继续可用,新密码逐步升级为更安全的算法,无需强制用户立即重置密码。
Q:Spring Security 的会话固定攻击防护默认是什么策略?为什么推荐保持默认?
默认策略是
migrateSession(),即用户认证成功后更换 Session ID,但保留原 Session 的所有属性。这比newSession()更轻量(无需复制属性),又比none()安全(防止攻击者先获取 Session ID 再诱骗用户登录)。保持默认的原因在于:Session Fixation 攻击的核心危害是攻击者预先知道用户登录后的 Session ID。migrateSession()在登录后立即使旧 ID 失效,攻击者持有的预登录 Session ID 无法再关联到已认证的安全上下文。
Q:为什么 Content-Security-Policy 中的 frame-ancestors 比 X-Frame-Options 更推荐使用?
X-Frame-Options仅支持DENY、SAMEORIGIN和ALLOW-FROM(后者已被废弃且兼容性差),无法精细控制允许嵌套的具体域名。CSP: frame-ancestors支持self、特定域名、甚至通配子域,语义更丰富,且与 CSP 的其他指令统一配置。Spring Security 中两者可同时启用,但现代安全审计更关注frame-ancestors的完整性。推荐配置为X-Frame-Options: SAMEORIGIN+CSP frame-ancestors 'self'的双重防护,兼顾旧浏览器兼容与现代浏览器策略。
版本说明:本教程基于 Spring Security 5.x(对应 Spring Boot 2.x)。
WebSecurityConfigurerAdapter、antMatchers、.csrf().disable()等写法为 5.x 经典风格。生产环境实践中,无论 5.x 还是 6.x,"最小权限 + 默认拒绝 + 纵深防御" 的设计原则始终不变。