返回博客

Spring Boot 安全加固:把「默认不信任」刻进架构里

安全在大多数后端项目里是后来者。功能先跑起来,安全「以后补」——这是大多数工程团队的真实状态。等到真正去补的时候,往往发现安全不是能「补上去」的东西,它需要从设计层面就确立一种基本姿态:默认不信任,必须显式开放

我整理了 Spring Boot 项目里最容易踩的几个安全盲区,以及我现在的处理思路。

认证与授权:两件事,别混在一起

认证(你是谁)和授权(你能做什么)是两个独立的关切点,但新手代码里经常把它们揉在一起。

认证层的职责很单一:验 Token 合法性,把身份写进 SecurityContext。用 OncePerRequestFilter 做这件事,逻辑不超过 20 行就够了——解出 Bearer Token,调一次 jwtService.authenticate(),塞进 SecurityContextHolder,然后放行到下一个 Filter。

授权层才是策略的地方。@EnableMethodSecurity + @PreAuthorize 是我目前最喜欢的组合,原因很简单:权限声明和业务代码挨在一起,代码 Review 时不会遗漏。

@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/users")
public List<UserDto> listUsers() { ... }
 
@PreAuthorize("@authz.isOwner(#id, authentication)")
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) { ... }

第二个 isOwner 是自定义 Bean 方法——把复杂的所有权判断逻辑收到一个地方,比在方法体里做 if (user.getId() != currentUser.getId()) throw ... 干净得多,也更容易测试。

一条硬规则:拒绝优先。没有明确授权的路径,默认拒绝。不要靠「这个接口没人知道」来做安全。

输入校验:越早越好,越靠近入口越好

SQL 注入的根本原因几乎总是同一个:开发者相信了来自外部的字符串。

Spring Data 的派生查询(findByEmailAndActiveTrue)和参数化绑定(:param)基本上消除了这类风险,但偶尔有人会因为写动态 SQL 而退回到字符串拼接。这是一条需要在团队里明确禁止的做法,不是「尽量避免」。

输入校验同样要靠近入口。Bean Validation 加在 DTO 上,Controller 标 @Valid,一行代码触发整套校验:

public record CreateUserDto(
    @NotBlank @Size(max = 100) String name,
    @NotBlank @Email String email,
    @Min(0) @Max(150) Integer age
) {}
 
@PostMapping("/users")
public ResponseEntity<UserDto> createUser(@Valid @RequestBody CreateUserDto dto) {
  return ResponseEntity.status(HttpStatus.CREATED).body(userService.create(dto));
}

校验失败统一由 @ControllerAdvice 里的 MethodArgumentNotValidException 处理器兜,响应格式保持一致,前端不用猜。

密码这一块单独说一句:BCrypt cost factor 建议调到 12,不是默认的 10。这个差距在用户感知上几乎不可见(几十毫秒),但暴力破解的成本翻了四倍。

CSRF 策略取决于你的 Session 模式

纯 API 服务(无状态 JWT)可以关掉 CSRF,配上 SessionCreationPolicy.STATELESS 即可。但如果你的服务里同时有浏览器会话(比如后台管理界面走 Cookie),CSRF 必须保留,并在前端请求里携带 Token。两种模式混存是最容易出问题的:别以为关掉 CSRF 就万事大吉,先确认你的 Session 模型。

密钥与配置:不在代码里,不在配置文件里

application.yml 里出现过明文密码,这件事在我带过的不止一个项目里发生过。原因很简单——开发环境调通之后就忘了改,然后被提交进了 Git。

解法不复杂:所有密钥、数据库密码、第三方 API Token 一律用环境变量占位符 ${ENV_VAR},生产环境通过部署平台注入,不进代码仓库。更进一步可以对接 Vault,但仅仅做到「不提交密钥」这一步就能规避 90% 的风险。

另一个容易被忽略的点:日志里不能出现 Token 和密码。结构化日志很方便,但这也意味着如果你直接 log 了整个请求对象或 DTO,password 字段会原原本本出现在日志系统里。敏感字段要在序列化前脱敏或过滤。

限流与安全头:最后一道线

限流不是性能优化,是安全防线。登录接口不做限流,暴力破解的成本几乎为零。Bucket4j 做 Filter 级限流很轻,按 IP 分桶,超限返回 429——这是对攻击者最直接的阻力。

但有一个坑:基于 IP 限流时,不能直接读 X-Forwarded-For Header——客户端可以伪造它。要先配置 server.forward-headers-strategy=FRAMEWORK 并注册 ForwardedHeaderFilter,确保反向代理会覆盖(不是追加)这个 Header,再从 request.getRemoteAddr() 拿 IP 才是可信的。

安全 Header 是另一条低成本、高回报的防线。Content-Security-PolicyX-Frame-OptionsReferrer-Policy 这些都可以在 SecurityFilterChain 里统一配置,几行代码就能关掉一类整整的攻击面。

一句话心法

安全不是功能清单上的最后一条,而是贯穿每个设计决策的默认姿态——拒绝优先,显式开放,密钥不进仓库,边界越早校验越好。