接口安全三层防护体系 --- 从原理到实现
为什么需要三层防护?
当两个系统通过 HTTP 进行通信时,面临三种核心威胁:
系统A ──────────── 网络(不可信) ────────────── 系统B
攻击者可以:
1. 窃听:偷看传输内容(得到你的业务数据)
2. 篡改:在传输过程中修改数据内容(改金额、改数量)
3. 伪造:冒充系统A向系统B发请求(假装是合法调用方)
三层防护分别针对这三种威胁:
| 层级 | 防护目标 | 手段 | 类比 |
|---|---|---|---|
| 第1层 | 防窃听 | AES 加密 | 把信装进保险箱,只有收件人有钥匙 |
| 第2层 | 防篡改 | 内容签名 | 信上盖骑缝章,动一个字就能发现 |
| 第3层 | 防伪造/重放 | 请求签名 | 快递员需要出示工牌+时间戳签到 |
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
第1层:内容加密(AES)--- 防窃听
要解决什么问题?
即使攻击者截获了 HTTP 请求包,也看不懂业务数据内容。
基础概念
AES(Advanced Encryption Standard) 是一种对称加密算法:
-
"对称":加密和解密用同一把密钥
-
双方需要事先约定好密钥(通常存在数据库或配置中心)
-
速度快,适合加密大量业务数据
明文:"金额=10000,客户=张三"
│
AES加密(密钥=abc123)
│
密文:"U2FsdGVkX1+3xY7..." ← 攻击者看到的是这个
│
AES解密(密钥=abc123)
│
明文:"金额=10000,客户=张三"
工作模式
AES 有多种工作模式,常见的:
| 模式 | 特点 | 安全性 |
|---|---|---|
| ECB | 每个块独立加密,最简单 | 较低(相同明文 → 相同密文) |
| CBC | 每个块与前一块关联,需要 IV | 较高 |
| GCM | 加密 + 认证一体,推荐 | 最高 |
通用示例代码
java
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
/**
* AES 加解密工具
* 作用:防止传输内容被第三方窃听
*/
public class AesEncryptor {
private static final String ALGORITHM = "AES";
// GCM 模式:同时提供加密和完整性校验
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int GCM_TAG_LENGTH = 128; // bits
private static final int IV_LENGTH = 12; // bytes
/**
* 加密
* @param plainText 原始业务数据(如 JSON 字符串)
* @param key 预共享密钥(双方约定,16/24/32字节)
* @return Base64编码的密文(包含IV)
*/
public static String encrypt(String plainText, String key) throws Exception {
// 生成随机 IV(初始化向量),保证同一明文每次加密结果不同
byte[] iv = new byte[IV_LENGTH];
new SecureRandom().nextBytes(iv);
SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(), ALGORITHM);
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec);
byte[] encrypted = cipher.doFinal(plainText.getBytes());
// IV + 密文 拼在一起传输(接收方需要 IV 来解密)
byte[] combined = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(combined);
}
/**
* 解密
* @param cipherText Base64编码的密文
* @param key 预共享密钥
* @return 原始业务数据
*/
public static String decrypt(String cipherText, String key) throws Exception {
byte[] combined = Base64.getDecoder().decode(cipherText);
// 拆分 IV 和密文
byte[] iv = new byte[IV_LENGTH];
byte[] encrypted = new byte[combined.length - IV_LENGTH];
System.arraycopy(combined, 0, iv, 0, IV_LENGTH);
System.arraycopy(combined, IV_LENGTH, encrypted, 0, encrypted.length);
SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(), ALGORITHM);
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);
return new String(cipher.doFinal(encrypted));
}
}
使用示例
java
String secretKey = "0123456789abcdef"; // 16字节密钥,实际从配置/DB中读取
String businessData = "{\"amount\":10000,\"customer\":\"张三\"}";
// 发送方:加密
String encrypted = AesEncryptor.encrypt(businessData, secretKey);
// encrypted = "dGhpcyBpcyBhIGVuY3J5cHRlZC..." ← 放到HTTP请求体中传输
// 接收方:解密
String decrypted = AesEncryptor.decrypt(encrypted, secretKey);
// decrypted = "{\"amount\":10000,\"customer\":\"张三\"}" ← 恢复原始数据
第2层:内容签名(MD5 + Base64)--- 防篡改
要解决什么问题?
确保接收方收到的数据跟发送方发出的一模一样,中间没有被人改过。
基础概念
签名原理:对内容做一个"指纹",接收方用同样的方法计算指纹,对比是否一致。
发送方:
content = "金额=10000"
sign = MD5(content + 密钥) = "a1b2c3d4..."
→ 把 content 和 sign 一起发出去
攻击者篡改:
content 被改为 "金额=99999"
但攻击者不知道密钥,无法重新计算正确的 sign
接收方:
收到 content = "金额=99999",sign = "a1b2c3d4..."
验算:MD5("金额=99999" + 密钥) = "x9y8z7..."
→ sign 不匹配!数据被篡改了!
为什么要加密钥?
如果 sign = MD5(content) 不加密钥:
- 攻击者改了 content 后,自己也能算出新的 MD5
- 把新 MD5 替换上去,接收方无法发现篡改
加了密钥后 sign = MD5(content + 密钥):
- 攻击者不知道密钥,算不出正确的 sign
- 这就是 HMAC(Hash-based Message Authentication Code) 的核心思想
通用示例代码
java
import java.security.MessageDigest;
import java.util.Base64;
/**
* 内容签名工具
* 作用:防止传输过程中数据被篡改
*
* 原理:发送方和接收方共享一个密钥(signKey),
* 发送方用 MD5(内容+密钥) 生成签名,
* 接收方用同样的方式验证。
*/
public class ContentSignature {
/**
* 生成签名
* @param content 业务数据内容(JSON字符串)
* @param signKey 签名密钥(双方约定)
* @return 签名字符串
*/
public static String sign(String content, String signKey) {
// 1. 拼接:内容 + 密钥
String raw = content + signKey;
// 2. MD5 计算摘要(32字符的十六进制字符串)
String md5Hex = md5(raw);
// 3. Base64 编码(方便在HTTP中传输)
return Base64.getEncoder().encodeToString(md5Hex.getBytes());
}
/**
* 验证签名
* @param content 收到的业务数据
* @param signKey 签名密钥
* @param receivedSign 收到的签名
* @return true=数据未被篡改
*/
public static boolean verify(String content, String signKey, String receivedSign) {
String expectedSign = sign(content, signKey);
return expectedSign.equals(receivedSign);
}
private static String md5(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes());
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
使用示例
java
String content = "{\"amount\":10000}";
String signKey = "my_secret_key_from_db";
// ===== 发送方 =====
String sign = ContentSignature.sign(content, signKey);
// 将 content(加密后)和 sign 一起放到请求体中:
// { "content": "加密后的数据", "sign": "YTFiMmMzZDQ..." }
// ===== 接收方 =====
// 收到请求后,先验签
boolean valid = ContentSignature.verify(decryptedContent, signKey, receivedSign);
if (!valid) {
throw new SecurityException("数据签名验证失败,可能被篡改");
}
// 验签通过,继续处理业务
第1层和第2层的关系
原始数据 "金额=10000"
│
├── 算签名 → sign = MD5("金额=10000" + signKey) → "abc123..."
│
└── 做加密 → encrypted = AES("金额=10000", cryptKey) → "U2Fs..."
最终传输:
{
"sign": "abc123...", ← 第2层:用于验证数据未被篡改
"content": "U2Fs..." ← 第1层:加密后的数据,防窃听
}
注意:签名是对原始明文算的,不是对密文算的。接收方需要先解密,再验签。
第3层:请求签名(Header 级别)--- 防伪造 / 防重放
要解决什么问题?
验证"这个请求确实是系统A发出的",而不是攻击者伪造的。同时防止攻击者把之前的合法请求再发一遍(重放攻击)。
什么是重放攻击?
正常流程:
系统A → "转账10000元" → 系统B ✓
重放攻击:
攻击者偷录了这个请求
攻击者 → "转账10000元"(原封不动重发)→ 系统B
系统B 以为又是系统A发的,又执行了一次转账!
防重放的核心:时间戳 + 随机数
请求签名 = MD5(requestId + timestamp + appId + appSecret)
防伪造:攻击者不知道 appSecret,算不出正确的签名
防重放:接收方检查 timestamp 是否在合理范围内(如±5分钟),
超时的请求直接拒绝
通用示例代码
java
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 请求签名工具
* 作用:1. 证明请求来自合法的调用方(身份认证)
* 2. 防止请求被重放(时间戳校验)
*
* 原理:将 requestId + 时间戳 + appId + appSecret 拼接后做 MD5,
* appSecret 只有合法调用方知道,所以能证明身份。
* 时间戳确保请求有时效性,过期的请求会被拒绝。
*/
public class RequestAuthenticator {
// ==================== 发送方(调用方)====================
/**
* 构建带签名的请求头
* @param appId 应用标识(公开的,告诉接收方"我是谁")
* @param appSecret 应用密钥(保密的,用于计算签名)
* @return 需要附加到 HTTP Header 中的键值对
*/
public static Map<String, String> buildSignedHeaders(String appId, String appSecret) {
// 1. 生成唯一请求ID(防止同一请求被重复处理)
String requestId = UUID.randomUUID().toString().replace("-", "");
// 2. 当前时间戳(毫秒)--- 接收方会检查是否过期
String timestamp = String.valueOf(System.currentTimeMillis());
// 3. 计算签名:MD5(requestId + "&" + timestamp + "&" + appId + "&" + appSecret)
String signContent = requestId + "&" + timestamp + "&" + appId + "&" + appSecret;
String sign = md5(signContent);
// 4. 组装请求头
Map<String, String> headers = new HashMap<>();
headers.put("X-App-Id", appId); // 公开:我是谁
headers.put("X-Request-Id", requestId); // 公开:本次请求唯一ID
headers.put("X-Request-Time", timestamp); // 公开:请求时间
headers.put("X-Sign", sign); // 公开:签名(但无法被伪造)
headers.put("X-Version", "1.0.0"); // 协议版本
return headers;
}
// ==================== 接收方(服务方)====================
/**
* 验证请求是否合法
* @param headers 收到的请求头
* @param appSecret 该 appId 对应的密钥(从数据库查出来)
* @return 验证结果
*/
public static AuthResult verifyRequest(Map<String, String> headers, String appSecret) {
String appId = headers.get("X-App-Id");
String requestId = headers.get("X-Request-Id");
String timestamp = headers.get("X-Request-Time");
String receivedSign = headers.get("X-Sign");
// 1. 检查时间戳 --- 防重放
long requestTime = Long.parseLong(timestamp);
long currentTime = System.currentTimeMillis();
long diff = Math.abs(currentTime - requestTime);
if (diff > 5 * 60 * 1000) { // 超过5分钟
return AuthResult.expired("请求已过期,时间差:" + diff + "ms");
}
// 2. 重新计算签名 --- 防伪造
String expectedSign = md5(requestId + "&" + timestamp + "&" + appId + "&" + appSecret);
if (!expectedSign.equals(receivedSign)) {
return AuthResult.invalid("签名验证失败");
}
// 3.(可选)检查 requestId 是否已处理过 --- 进一步防重放
// if (redis.exists("processed:" + requestId)) {
// return AuthResult.duplicate("请求已处理过");
// }
// redis.setex("processed:" + requestId, 300, "1"); // 5分钟内不允许重复
return AuthResult.success();
}
private static String md5(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes());
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// 验证结果封装
public static class AuthResult {
private final boolean success;
private final String message;
private AuthResult(boolean success, String message) {
this.success = success;
this.message = message;
}
public static AuthResult success() { return new AuthResult(true, "OK"); }
public static AuthResult expired(String msg) { return new AuthResult(false, msg); }
public static AuthResult invalid(String msg) { return new AuthResult(false, msg); }
public static AuthResult duplicate(String msg) { return new AuthResult(false, msg); }
public boolean isSuccess() { return success; }
public String getMessage() { return message; }
}
}
使用示例
java
// ===== 调用方发送请求 =====
String appId = "my_app_001";
String appSecret = "super_secret_key"; // 绝不能泄露
Map<String, String> headers = RequestAuthenticator.buildSignedHeaders(appId, appSecret);
// headers:
// X-App-Id: my_app_001
// X-Request-Id: a3f8b2c4e5d6...
// X-Request-Time: 1720000000000
// X-Sign: e8b7a2f1c3d4...
// 把这些 headers 加到 HTTP 请求中
// ===== 服务方收到请求 =====
// 1. 从 X-App-Id 查出对应的 appSecret
String storedSecret = configRepository.getSecretByAppId("my_app_001");
// 2. 验证
AuthResult result = RequestAuthenticator.verifyRequest(receivedHeaders, storedSecret);
if (!result.isSuccess()) {
throw new UnauthorizedException(result.getMessage());
}
// 验证通过,继续处理业务
三层如何协同工作
完整的一次安全请求流程:
┌─────────── 发送方 ───────────┐ ┌─────────── 接收方 ───────────┐
│ │ │ │
│ 1. 准备业务数据 │ │ │
│ data = "金额=10000" │ │ │
│ │ │ │
│ 2. 【第2层】计算内容签名 │ │ │
│ sign = MD5(data+signKey) │ │ │
│ │ │ │
│ 3. 【第1层】加密内容 │ │ │
│ encrypted = AES(data) │ │ │
│ │ │ │
│ 4. 组装请求体 │ │ │
│ body = { │ │ │
│ "sign": sign, │ │ │
│ "content": encrypted │ │ │
│ } │ │ │
│ │ │ │
│ 5. 【第3层】计算请求签名 │ │ │
│ headerSign = MD5( │ │ │
│ requestId+time+appId │ │ │
│ +appSecret) │ │ │
│ │ │ │
│ 6. 发送 HTTP POST │ │ 7. 收到请求 │
│ Headers: X-Sign等 │ ──→ │ │
│ Body: body │ │ 8. 【第3层】验证请求签名 │
│ │ │ → 失败:拒绝(伪造/过期) │
│ │ │ │
│ │ │ 9. 【第1层】解密内容 │
│ │ │ data = AES.decrypt( │
│ │ │ encrypted, cryptKey) │
│ │ │ │
│ │ │ 10.【第2层】验证内容签名 │
│ │ │ check: MD5(data+signKey) │
│ │ │ == sign? │
│ │ │ → 失败:拒绝(数据被篡改) │
│ │ │ │
│ │ │ 11. 验证全通过,处理业务 │
└──────────────────────────────┘ └──────────────────────────────┘
完整集成示例
java
/**
* 安全远程调用客户端 --- 集成三层防护
*/
public class SecureRemoteClient {
private final String signKey; // 内容签名密钥
private final String cryptKey; // AES加密密钥
private final String appId; // 应用标识
private final String appSecret; // 应用密钥(请求签名用)
public SecureRemoteClient(String signKey, String cryptKey, String appId, String appSecret) {
this.signKey = signKey;
this.cryptKey = cryptKey;
this.appId = appId;
this.appSecret = appSecret;
}
/**
* 发送安全请求
*/
public String sendSecureRequest(String url, Object businessData) throws Exception {
// 序列化业务数据
String jsonContent = toJson(businessData);
// 第2层:计算内容签名
String sign = ContentSignature.sign(jsonContent, signKey);
// 第1层:加密业务内容
String encrypted = AesEncryptor.encrypt(jsonContent, cryptKey);
// 组装请求体
Map<String, String> body = new HashMap<>();
body.put("sign", sign);
body.put("content", encrypted);
body.put("timestamp", String.valueOf(System.currentTimeMillis()));
String bodyJson = toJson(body);
// 第3层:计算请求头签名
Map<String, String> headers = RequestAuthenticator.buildSignedHeaders(appId, appSecret);
headers.put("Content-Type", "application/json");
// 发送 HTTP 请求
return httpPost(url, bodyJson, headers);
}
}
/**
* 安全远程调用服务端 --- 集成三层验证
*/
public class SecureRemoteServer {
private final String signKey;
private final String cryptKey;
private final String appSecret;
public String handleRequest(Map<String, String> headers, String requestBody) throws Exception {
// 第3层验证:请求是否来自合法调用方,是否过期
AuthResult authResult = RequestAuthenticator.verifyRequest(headers, appSecret);
if (!authResult.isSuccess()) {
throw new SecurityException("请求认证失败: " + authResult.getMessage());
}
// 解析请求体
Map<String, String> body = parseJson(requestBody);
// 第1层解密:恢复业务数据明文
String decrypted = AesEncryptor.decrypt(body.get("content"), cryptKey);
// 第2层验证:内容是否被篡改
boolean signValid = ContentSignature.verify(decrypted, signKey, body.get("sign"));
if (!signValid) {
throw new SecurityException("内容签名验证失败,数据可能被篡改");
}
// 三层验证全部通过,安全地处理业务数据
return processBusinessLogic(decrypted);
}
}
各层可以独立使用吗?
可以,根据安全需求选择:
| 场景 | 需要的层级 | 原因 |
|---|---|---|
| 内网微服务调用 | 可能只需第3层 | 内网不怕窃听,但要防止非法调用 |
| 传输敏感数据(金额/身份证) | 全部三层 | 既怕看到,也怕改,也怕伪造 |
| 开放平台 API | 第3层 + HTTPS | HTTPS已加密防窃听,额外做身份认证 |
| 支付回调通知 | 第2层 + 第3层 | 必须验证来源合法且内容未被改 |
与 HTTPS 的区别
"我已经用了 HTTPS,为什么还需要这三层?"
| 维度 | HTTPS | 自定义三层防护 |
|---|---|---|
| 防窃听 | ✅ TLS 加密 | ✅ AES 加密 |
| 防篡改 | ✅ TLS 完整性 | ✅ 内容签名 |
| 身份认证 | ✅ 证书(单向或双向) | ✅ appId + appSecret |
| 防重放 | ❌ 不防 | ✅ 时间戳 + requestId |
| 端到端安全 | ❌ 到网关就解密了 | ✅ 业务层面端到端 |
| 内部可审计 | ❌ 解密后明文流转 | ✅ 中间件看不到明文 |
关键区别:HTTPS 保护的是"传输通道",自定义签名保护的是"业务数据本身"。
在企业内部,请求经常要经过网关、负载均衡、中间件转发,HTTPS 在网关层就终止了(解密成明文再转发到内部服务)。自定义加密+签名能保证即使在内部链路中也不被篡改或伪造。
常见算法选型参考
| 用途 | 推荐算法 | 项目中用的 | 备注 |
|---|---|---|---|
| 内容加密 | AES-256-GCM | AES/ECB | ECB 安全性较低,建议升级 |
| 内容签名 | HMAC-SHA256 | MD5+Base64 | MD5 已有碰撞攻击,建议升级 |
| 请求签名 | HMAC-SHA256 | MD5 | 同上 |
| 密钥交换 | RSA / ECDH | 预共享(DB存储) | 适合长期合作的系统间 |
总结速记
第1层 AES → 把信封起来(别人看不懂)
第2层 签名 → 信上盖章(改了就能发现)
第3层 认证 → 出示身份证+签到时间(证明是本人,且不是录像重播)