RESTful API 安全
概述
RESTful API 作为现代微服务架构和前后端分离模式的核心通信方式,其安全性直接关系到整个系统的数据安全和业务稳定性。API 接口暴露在公网环境中,面临着参数篡改、身份伪造、重放攻击、数据泄露等多维度的安全威胁。本文从参数校验、签名验签、防重放攻击、加密传输、HTTP 方法安全和状态码安全六个维度,系统性地阐述 RESTful API 的安全防护策略,并结合 Java Spring Boot 提供可落地的代码示例。
一、参数校验
参数校验是 API 安全的第一道防线。任何来自客户端的输入都不可信任,必须经过严格的校验才能进入业务逻辑处理。
1.1 输入白名单与黑名单
白名单策略:明确指定允许输入的字符集合或值范围,拒绝所有不在白名单内的输入。例如,对于枚举类型的参数,只接受预定义的枚举值。
黑名单策略:识别并拦截已知的恶意输入模式,如 SQL 关键字、HTML 标签等。黑名单难以覆盖所有攻击变种,因此推荐以白名单为主、黑名单为辅的组合策略。
// 白名单校验示例 - 仅允许字母和数字
public boolean validateUsername(String username) {
return username != null && username.matches("^[a-zA-Z0-9_]{4,20}$");
}
// 枚举值白名单
public boolean validateRole(String role) {
return Arrays.asList("ADMIN", "USER", "GUEST").contains(role);
}1.2 类型校验与长度限制
对每个请求参数明确声明期望的数据类型,并在反序列化时进行严格校验。同时,所有字符串类型参数必须施加长度限制,防止缓冲区溢出和资源耗尽攻击。
@PostMapping("/user")
public ResponseEntity<?> createUser(@Valid @RequestBody UserCreateRequest request) {
// 通过 @Valid 触发 JSR-303 Bean Validation
userService.createUser(request);
return ResponseEntity.ok().build();
}
@Data
public class UserCreateRequest {
@NotBlank(message = "用户名不能为空")
@Size(min = 4, max = 20, message = "用户名长度需在 4-20 之间")
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "用户名只允许字母、数字和下划线")
private String username;
@NotNull
@Min(1) @Max(150)
private Integer age;
@Email(message = "邮箱格式不正确")
@Size(max = 100)
private String email;
}1.3 SQL 注入防御
防御 SQL 注入的核心原则是永远不要拼接 SQL 字符串。使用参数化查询(PreparedStatement)或 ORM 框架(MyBatis、JPA)的内置参数绑定机制。
// 危险做法:字符串拼接
String sql = "SELECT * FROM users WHERE username = '" + username + "'";
// 安全做法:参数化查询
@Query("SELECT u FROM User u WHERE u.username = :username")
Optional<User> findByUsername(@Param("username") String username);1.4 XSS 过滤
对于需要返回 HTML 内容的 API 接口,必须对用户输入中的 HTML 标签和特殊字符进行转义。在 Spring Boot 中,可以通过注册自定义的 HtmlCharacterEscapes 来实现全局的 JSON 序列化转义。
@Bean
public Jackson2ObjectMapperBuilderCustomizer xssEscapingCustomizer() {
return builder -> {
builder.serializerByType(String.class, new StringSerializer() {
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider provider)
throws IOException {
String escaped = HtmlUtils.htmlEscape(value, "UTF-8");
gen.writeString(escaped);
}
});
};
}二、签名验签
签名验签机制用于确保 API 请求的完整性和身份真实性,防止请求在传输过程中被篡改,同时验证调用方的合法身份。
2.1 HMAC 对称签名
HMAC(Hash-based Message Authentication Code)使用共享密钥对请求内容进行哈希运算,生成签名。服务端使用相同的密钥和算法重新计算签名并进行比对。
签名生成流程:
- 对请求参数按字典序排序
- 拼接规范化的待签名字符串
- 使用 App Secret 作为密钥,通过 HMAC-SHA256 计算签名
- 将签名放入请求头
X-Signature
public class HmacSigner {
public static String sign(Map<String, String> params, String secret) {
// 1. 参数排序
TreeMap<String, String> sorted = new TreeMap<>(params);
// 2. 拼接待签名字符串
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : sorted.entrySet()) {
sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
}
sb.append("key=").append(secret);
// 3. HMAC-SHA256 签名
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
mac.init(keySpec);
byte[] hash = mac.doFinal(sb.toString().getBytes(StandardCharsets.UTF_8));
// 4. Base64 编码
return Base64.getEncoder().encodeToString(hash);
}
}2.2 RSA 非对称签名
相比 HMAC,RSA 非对称签名使用私钥签名、公钥验签的方式,私钥仅由服务端持有,公钥分发给调用方。这种方式适用于开放平台场景,多个第三方应用接入时无需共享同一个密钥。
public class RsaSigner {
public static String sign(String content, PrivateKey privateKey) throws Exception {
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(privateKey);
signature.update(content.getBytes(StandardCharsets.UTF_8));
byte[] signed = signature.sign();
return Base64.getEncoder().encodeToString(signed);
}
public static boolean verify(String content, String sign, PublicKey publicKey) throws Exception {
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initVerify(publicKey);
signature.update(content.getBytes(StandardCharsets.UTF_8));
return signature.verify(Base64.getDecoder().decode(sign));
}
}2.3 App Secret 管理
App Secret 是签名体系中的核心敏感信息,必须遵循以下管理原则:
- 分级存储:开发环境、测试环境、生产环境使用不同的 Secret
- 定期轮换:每 90 天强制轮换一次,支持新旧 Secret 的平滑过渡期
- 加密存储:数据库中的 Secret 字段使用 AES 加密,配置中心使用加密存储
- 访问控制:只有授权人员可查看和修改 Secret 配置
- 审计日志:所有 Secret 的创建、修改、吊销操作需记录审计日志
2.4 时间戳防重放
在签名参数中引入时间戳 timestamp,服务端校验签名前先检查时间戳是否在允许的时间窗口内(如 5 分钟),超出窗口的请求直接拒绝。
public boolean validateTimestamp(long timestamp) {
long now = System.currentTimeMillis();
long window = 5 * 60 * 1000L; // 5 分钟窗口
return Math.abs(now - timestamp) <= window;
}2.5 Nonce 一次性随机数
nonce(Number Used Once)是一个一次性随机字符串,每个请求携带唯一的 nonce,服务端缓存已使用过的 nonce 并设置与时间窗口一致的过期时间。结合时间戳,可有效防止签名被截获后的重放攻击。
@Component
public class NonceService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final long NONCE_EXPIRE_SECONDS = 300; // 5 分钟
public boolean checkAndMarkNonce(String nonce) {
// SET NX 原子操作,如果 key 已存在则返回 false
Boolean success = redisTemplate.opsForValue()
.setIfAbsent("nonce:" + nonce, "1", NONCE_EXPIRE_SECONDS, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
}三、防重放攻击
重放攻击指攻击者截获合法的 API 请求后,将请求再次发送到服务端以执行非预期的操作。即使请求有签名保护,如果服务端不进行重放检测,攻击者依然可以重复提交被截获的请求。
3.1 时间戳窗口 + Nonce 缓存
这是最常见的防重放方案,结合时间戳和 nonce 实现双重防护:
- 客户端在请求中包含
timestamp(当前时间戳)和nonce(随机字符串),并对这些参数进行签名 - 服务端校验签名通过后,检查
timestamp是否在合法时间窗口内(如now - timestamp < 300s) - 检查
nonce是否已经在 Redis 缓存中存在,若存在则判定为重放请求 - nonce 缓存的 TTL 与时间窗口一致,过期自动清理
public boolean antiReplayCheck(String nonce, long timestamp) {
// 第一步:时间窗口校验
if (Math.abs(System.currentTimeMillis() - timestamp) > 300_000) {
throw new ApiException("请求已过期");
}
// 第二步:nonce 去重校验
if (!nonceService.checkAndMarkNonce(nonce)) {
throw new ApiException("重复的请求");
}
return true;
}3.2 幂等性设计
对于转账、下单等敏感操作,API 应具备幂等性——相同的请求无论提交多少次,业务结果都相同。实现方案包括:
- 唯一请求号(Idempotent Key):客户端为每个幂等请求生成全局唯一的
Idempotent-Key,服务端根据该 key 进行去重 - 去重表:使用数据库唯一索引或 Redis SET NX 来保证请求幂等性
@PostMapping("/order")
public ResponseEntity<?> createOrder(
@RequestHeader("Idempotent-Key") String idempotentKey,
@Valid @RequestBody OrderRequest request) {
// 检查幂等 key 是否已存在
if (idempotentService.keyExists(idempotentKey)) {
// 返回已有的处理结果
return ResponseEntity.ok(idempotentService.getResult(idempotentKey));
}
// 执行业务逻辑
OrderResult result = orderService.createOrder(request);
// 保存幂等 key 与处理结果
idempotentService.saveResult(idempotentKey, result);
return ResponseEntity.ok(result);
}3.3 去重表设计
去重表使用数据库的唯一索引来保证请求只被处理一次:
CREATE TABLE idempotent_record (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
idempotent_key VARCHAR(128) NOT NULL,
response_body TEXT,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX uk_idempotent_key (idempotent_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 定期清理过期记录(超过 24 小时的记录可删除)
DELETE FROM idempotent_record WHERE create_time < DATE_SUB(NOW(), INTERVAL 24 HOUR);四、加密传输
4.1 HTTPS 强制
所有 API 接口必须强制使用 HTTPS,禁止 HTTP 明文传输。HTTPS 通过 TLS 协议提供了传输层的加密保护,防止中间人攻击和窃听。
# application.yml - Spring Boot HTTPS 配置
server:
port: 443
ssl:
enabled: true
key-store: classpath:keystore.p12
key-store-password: ${SSL_KEY_PASSWORD}
key-store-type: PKCS12
key-alias: api-server同时,应配置 HTTP 自动跳转到 HTTPS:
@Configuration
public class HttpsRedirectConfig {
@Bean
public TomcatServletWebServerFactory servletContainer() {
TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory() {
@Override
protected void postProcessContext(Context context) {
SecurityConstraint constraint = new SecurityConstraint();
constraint.setUserConstraint("CONFIDENTIAL");
SecurityCollection collection = new SecurityCollection();
collection.addPattern("/*");
constraint.addCollection(collection);
context.addConstraint(constraint);
}
};
factory.addAdditionalTomcatConnectors(createHttpConnector());
return factory;
}
private Connector createHttpConnector() {
Connector connector = new Connector(TomcatServletWebServerFactory.DEFAULT_PROTOCOL);
connector.setScheme("http");
connector.setPort(8080);
connector.setSecure(false);
connector.setRedirectPort(443);
return connector;
}
}4.2 请求/响应体加密
对于敏感业务场景,可在应用层对请求体和响应体进行加密,实现端到端的加密保护。
对称加密(AES-GCM):适用于内部服务间通信,性能高,密钥通过密钥管理服务(KMS)分发。
非对称加密(RSA/OAEP):适用于开放平台,客户端使用服务端的公钥加密 AES 密钥,再使用 AES 密钥加密业务数据。
public class AesEncryptor {
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
public static String encrypt(String plainText, SecretKey key) throws Exception {
byte[] iv = new byte[GCM_IV_LENGTH];
SecureRandom random = new SecureRandom();
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, key, spec);
byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
byte[] encrypted = new byte[GCM_IV_LENGTH + cipherText.length];
System.arraycopy(iv, 0, encrypted, 0, GCM_IV_LENGTH);
System.arraycopy(cipherText, 0, encrypted, GCM_IV_LENGTH, cipherText.length);
return Base64.getEncoder().encodeToString(encrypted);
}
public static String decrypt(String encryptedData, SecretKey key) throws Exception {
byte[] decoded = Base64.getDecoder().decode(encryptedData);
byte[] iv = Arrays.copyOfRange(decoded, 0, GCM_IV_LENGTH);
byte[] cipherText = Arrays.copyOfRange(decoded, GCM_IV_LENGTH, decoded.length);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, key, spec);
return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8);
}
}4.3 字段级加密
在数据库中存储用户的敏感信息(如身份证号、手机号、银行卡号)时,应使用字段级加密。即使数据库被泄露,攻击者也无法获取明文数据。
@Converter
public class SensitiveDataConverter implements AttributeConverter<String, String> {
private static final String ENCRYPTION_KEY = System.getenv("FIELD_ENCRYPTION_KEY");
@Override
public String convertToDatabaseColumn(String attribute) {
if (attribute == null) return null;
return AesEncryptor.encrypt(attribute, decodeKey(ENCRYPTION_KEY));
}
@Override
public String convertToEntityAttribute(String dbData) {
if (dbData == null) return null;
return AesEncryptor.decrypt(dbData, decodeKey(ENCRYPTION_KEY));
}
private SecretKey decodeKey(String key) {
byte[] decoded = Base64.getDecoder().decode(key);
return new SecretKeySpec(decoded, "AES");
}
}五、HTTP 方法安全
5.1 GET
- 仅用于查询:GET 请求必须幂等,不得对服务端产生副作用(增、删、改)
- 参数不可包含敏感信息:GET 参数会出现在 URL 和服务器日志中,不得传输密码、Token 等敏感数据
- 长度限制:URL 长度受浏览器和服务器限制,长参数应使用 POST
5.2 POST
- 创建资源:用于新增数据,非幂等,每次调用都会创建新资源
- 请求体加密:POST 请求体应使用 HTTPS 加密传输
- CSRF 防护:对 POST 请求实施 CSRF 令牌校验
5.3 PUT
- 全量更新:PUT 应是幂等的,多次调用结果相同
- 必须包含完整资源:PUT 请求需提供资源的全量字段,缺失的字段应视为被置空
5.4 DELETE
- 幂等操作:多次调用 DELETE 同一资源的结果相同
- 谨慎授权:DELETE 操作必须有严格的身份验证和权限检查
- 软删除优先:建议优先使用软删除(逻辑删除)而非物理删除
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
// GET - 安全且幂等
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id));
}
// POST - 非幂等,创建资源
@PostMapping
public ResponseEntity<User> createUser(@Valid @RequestBody UserCreateRequest request) {
User user = userService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
// PUT - 幂等,全量更新
@PutMapping("/{id}")
public ResponseEntity<User> updateUser(@PathVariable Long id,
@Valid @RequestBody UserUpdateRequest request) {
User user = userService.update(id, request);
return ResponseEntity.ok(user);
}
// DELETE - 幂等
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.softDelete(id);
return ResponseEntity.noContent().build();
}
}5.5 全局 HTTP 方法限制
在安全配置层面,应限制 API 端点只允许预期的 HTTP 方法:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.and()
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/users/**").hasRole("USER")
.anyRequest().authenticated()
)
// 限制 HTTP 方法
.requiresChannel(channel -> channel
.anyRequest().requiresSecure()
);
return http.build();
}
}六、HTTP 状态码安全
API 返回的 HTTP 状态码应当遵循标准语义,同时注意避免泄露过度详细的内部信息。
6.1 推荐使用的状态码
| 状态码 | 含义 | 使用场景 |
|---|---|---|
| 200 OK | 请求成功 | GET/PUT 成功返回资源 |
| 201 Created | 资源创建成功 | POST 创建资源成功 |
| 204 No Content | 无内容返回 | DELETE 删除成功 |
| 400 Bad Request | 请求参数错误 | 参数校验失败 |
| 401 Unauthorized | 未认证 | 缺少或无效的认证凭据 |
| 403 Forbidden | 无权限 | 已认证但无操作权限 |
| 404 Not Found | 资源不存在 | 请求的资源不存在 |
| 429 Too Many Requests | 请求频率超限 | 触发限流策略 |
6.2 避免泄露内部信息
错误响应的设计应遵循"对外最小化、对内最大化"原则,对外部客户端只返回必要的信息,内部详情记录到服务端日志。
// 推荐做法:统一错误响应体
public class ApiError {
private int status;
private String message; // 对用户友好的描述
private String errorCode; // 业务错误码
// 不包含 stackTrace、exceptionType 等内部信息
}
// 全局异常处理
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ApiError> handleDatabaseError(DataAccessException ex) {
// 记录详细异常到日志
log.error("数据库异常", ex);
// 返回泛化的错误信息
ApiError error = new ApiError(500, "系统内部错误", "INTERNAL_ERROR");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiError> handleAccessDenied(AccessDeniedException ex) {
ApiError error = new ApiError(403, "无权限访问", "FORBIDDEN");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
}
}6.3 常见错误做法
以下做法会将内部实现细节暴露给攻击者,应严格避免:
- ❌ 返回 500 时包含
stack trace信息 - ❌ 返回 401 时提示"用户不存在"或"密码错误"(应统一返回"认证失败")
- ❌ 返回 404 时推断出资源 ID 的格式或范围
- ❌ 返回详细的数据库错误信息(如"Duplicate entry 'xxx' for key 'uk_email'")
- ❌ 暴露框架或中间件的版本号
七、综合安全拦截器示例
将上述各项安全机制组合为统一的拦截器链,对每个 API 请求进行完整的校验处理。
@Component
public class ApiSecurityInterceptor implements HandlerInterceptor {
@Autowired
private NonceService nonceService;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 1. 从请求头中提取安全参数
String signature = request.getHeader("X-Signature");
String nonce = request.getHeader("X-Nonce");
String timestampStr = request.getHeader("X-Timestamp");
String appId = request.getHeader("X-App-Id");
if (StringUtils.isAnyBlank(signature, nonce, timestampStr, appId)) {
writeErrorResponse(response, 401, "缺少安全请求头");
return false;
}
// 2. 时间窗口校验
long timestamp = Long.parseLong(timestampStr);
if (Math.abs(System.currentTimeMillis() - timestamp) > 300_000) {
writeErrorResponse(response, 401, "请求已过期");
return false;
}
// 3. Nonce 去重校验
if (!nonceService.checkAndMarkNonce(nonce)) {
writeErrorResponse(response, 401, "重复请求");
return false;
}
// 4. 签名校验
Map<String, String> params = extractParams(request);
params.put("timestamp", timestampStr);
params.put("nonce", nonce);
params.put("appId", appId);
String secret = appSecretManager.getSecret(appId);
String expectedSign = HmacSigner.sign(params, secret);
if (!expectedSign.equals(signature)) {
writeErrorResponse(response, 401, "签名无效");
return false;
}
return true;
}
private void writeErrorResponse(HttpServletResponse response,
int status,
String message) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(status);
response.getWriter().write(JsonUtils.toJson(new ApiError(status, message)));
}
}八、安全配置清单
以下总结了 RESTful API 安全的最佳实践清单,可作为项目安全评审的参考依据:
| 类别 | 检查项 | 优先级 |
|---|---|---|
| 传输安全 | 强制 HTTPS,配置 HSTS 头 | P0 |
| 传输安全 | 禁用不安全的 TLS 版本(TLS 1.0/1.1) | P0 |
| 传输安全 | 配置合理的密码套件白名单 | P1 |
| 认证鉴权 | 使用 JWT/OAuth2 进行身份认证 | P0 |
| 认证鉴权 | 敏感操作校验用户权限 | P0 |
| 参数校验 | 所有输入参数进行白名单校验 | P0 |
| 参数校验 | 使用参数化查询防御 SQL 注入 | P0 |
| 参数校验 | 对输出内容进行 XSS 编码 | P1 |
| 签名验签 | 敏感接口实施签名机制 | P0 |
| 签名验签 | App Secret 加密存储并定期轮换 | P0 |
| 防重放 | 时间戳窗口 + Nonce 缓存 | P1 |
| 防重放 | 幂等接口设计 | P1 |
| 日志安全 | 不记录敏感字段(密码、Token、银行卡号) | P0 |
| 日志安全 | 错误响应不暴露堆栈信息 | P0 |
| 限流熔断 | API 级别限流保护 | P1 |
| 限流熔断 | 针对单个 AppId 的调用频率限制 | P1 |
总结
RESTful API 安全是一个系统工程,不能依赖单一的防御手段。本文从参数校验、签名验签、防重放攻击、加密传输、HTTP 方法安全和状态码安全六个核心维度出发,结合 Java Spring Boot 框架给出了具体的实现方案。在实际项目中,应根据业务场景的安全等级要求,选择合适的防护组合,并定期进行安全审计和渗透测试,持续提升 API 的整体安全水位。
安全无小事,每一层防护都是纵深防御体系的重要组成部分。在设计 API 之初就将安全纳入考量,远比上线后再进行安全修补要高效和经济得多。