开放 API 签名方案设计
本文档详细记录开放 API 签名规则的多种方案设计,供技术选型参考。
1. 方案一:极简签名
1.1 签名规则
签名字符串 = Method + "\n" + URL路径 + "\n" + Timestamp
签名值 = HMAC-SHA256(签名字符串, SecretKey)
2.2 请求头格式
Authorization: Octo ak=AK_xxx,ts=1704067200000,sign=a1b2c3d4e5f6...
| 字段 | 说明 |
|---|
| ak | AccessKey(应用标识) |
| ts | Timestamp(毫秒级时间戳) |
| sign | 签名值 |
2.3 签名示例
GET 请求
请求:GET /openapi/v1/devices?productKey=Park001&pageNum=1&pageSize=10
Timestamp:1704067200000
签名字符串:
GET\n
/openapi/v1/devices\n
1704067200000
签名值:
HMAC-SHA256("GET\n/openapi/v1/devices\n1704067200000", "sk_secret")
= "a1b2c3d4e5f6..."
POST 请求
请求:POST /openapi/v1/devices
Timestamp:1704067200000
RequestBody:{"productKey":"Park001","deviceId":"Light-001","deviceName":"路灯001"}
签名字符串:
POST\n
/openapi/v1/devices\n
1704067200000
签名值:
HMAC-SHA256("POST\n/openapi/v1/devices\n1704067200000", "sk_secret")
= "b2c3d4e5f6a1..."
2.4 服务端验证流程
1. 解析 Authorization 头,提取 ak、ts、sign
2. 验证 Timestamp 是否在 ±5 分钟内
3. 根据 AccessKey 查询 SecretKey
4. 重新计算签名:sign' = HMAC-SHA256(Method + "\n" + Path + "\n" + ts, SecretKey)
5. 比对 sign 和 sign'
2.5 客户端实现(Java)
import cn.hutool.crypto.SecureUtil;
public class SimpleSignatureClient {
private final String accessKey;
private final String secretKey;
public SimpleSignatureClient(String accessKey, String secretKey) {
this.accessKey = accessKey;
this.secretKey = secretKey;
}
/**
* 生成签名
*/
public String sign(String method, String path, long timestamp) {
String stringToSign = method + "\n" + path + "\n" + timestamp;
return SecureUtil.hmacSha256(secretKey).digestHex(stringToSign);
}
/**
* 构建 Authorization 头
*/
public String buildAuthorization(String method, String path) {
long timestamp = System.currentTimeMillis();
String signature = sign(method, path, timestamp);
return "Octo ak=" + accessKey + ",ts=" + timestamp + ",sign=" + signature;
}
}
2.6 优缺点分析
| 优点 | 缺点 |
|---|
| 极简,无歧义 | 无法防止参数篡改 |
| 不区分参数来源 | 签名不包含请求内容 |
| 客户端实现简单 | 理论上可被中间人替换请求体 |
| 对接成本低 | 安全性依赖 HTTPS |
2.8 安全评估
| 攻击类型 | 防护能力 | 说明 |
|---|
| 重放攻击 | ✅ 有效 | Timestamp ±5分钟有效期 |
| 身份伪造 | ✅ 有效 | SecretKey 只有应用知道 |
| 参数篡改 | ❌ 无效 | 签名不包含任何参数 |
| 中间人攻击 | ⚠️ 部分 | 完全依赖 HTTPS 保护 |
2.9 适用场景
- 内网/可信网络环境
- 快速对接,优先考虑开发效率
- 已有 HTTPS 加密传输保障
- 对安全性要求不高的业务场景
2. 方案二:统一参数签名(推荐)
3.1 签名规则
签名字符串 = Method + "\n" + URL路径 + "\n" + Timestamp + "\n" + ContentHash
签名值 = HMAC-SHA256(签名字符串, SecretKey)
3.2 ContentHash 计算规则
| 请求类型 | ContentHash |
|---|
| GET | 空字符串 "" |
| DELETE | 空字符串 "" |
| POST | SHA256(RequestBody JSON字符串) |
| PUT | SHA256(RequestBody JSON字符串) |
3.3 签名示例
GET 请求
请求:GET /openapi/v1/devices?productKey=Park001&pageNum=1&pageSize=10
Timestamp:1704067200000
ContentHash = ""(GET 请求无请求体)
签名字符串:
GET\n
/openapi/v1/devices\n
1704067200000\n
签名值:
HMAC-SHA256("GET\n/openapi/v1/devices\n1704067200000\n", "sk_secret")
= "a1b2c3d4e5f6..."
POST 请求
请求:POST /openapi/v1/devices
Timestamp:1704067200000
RequestBody:{"productKey":"Park001","deviceId":"Light-001","deviceName":"路灯001"}
ContentHash = SHA256(RequestBody)
= "e10adc3949ba59abbe56e057f20f883e278f5c5f6d8e9a0b1c2d3e4f5a6b7c8d"
签名字符串:
POST\n
/openapi/v1/devices\n
1704067200000\n
e10adc3949ba59abbe56e057f20f883e278f5c5f6d8e9a0b1c2d3e4f5a6b7c8d
签名值:
HMAC-SHA256(签名字符串, "sk_secret")
= "b2c3d4e5f6a1..."
PUT 请求
请求:PUT /openapi/v1/shadow/desired
Timestamp:1704067200000
RequestBody:{"productKey":"Park001","deviceId":"Light-001","desired":{"Power":"on"}}
ContentHash = SHA256(RequestBody)
= "098f6bcd4621d373cade4e832627b4f6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
签名字符串:
PUT\n
/openapi/v1/shadow/desired\n
1704067200000\n
098f6bcd4621d373cade4e832627b4f6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
签名值:
HMAC-SHA256(签名字符串, "sk_secret")
= "c3d4e5f6a1b2..."
3.4 服务端验证流程
1. 解析 Authorization 头,提取 ak、ts、sign
2. 验证 Timestamp 是否在 ±5 分钟内
3. 根据 AccessKey 查询 SecretKey
4. 读取请求体(需缓存请求体以便重复读取)
5. 计算 ContentHash:
- GET/DELETE: ""
- POST/PUT: SHA256(RequestBody)
6. 重新计算签名并比对
3.5 客户端实现(Java)
import org.apache.commons.codec.digest.DigestUtils;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.core.util.StrUtil;
public class UnifiedSignatureClient {
private final String accessKey;
private final String secretKey;
public UnifiedSignatureClient(String accessKey, String secretKey) {
this.accessKey = accessKey;
this.secretKey = secretKey;
}
/**
* 生成签名
*/
public String sign(String method, String path, long timestamp, String requestBody) {
String contentHash = calculateContentHash(method, requestBody);
String stringToSign = method + "\n" + path + "\n" + timestamp + "\n" + contentHash;
return SecureUtil.hmacSha256(secretKey).digestHex(stringToSign);
}
/**
* 计算 ContentHash
*/
private String calculateContentHash(String method, String requestBody) {
// GET/DELETE 无请求体,返回空字符串
if ("GET".equalsIgnoreCase(method) || "DELETE".equalsIgnoreCase(method)) {
return "";
}
// POST/PUT 返回请求体的 SHA256
if (StrUtil.isNotBlank(requestBody)) {
return DigestUtils.sha256Hex(requestBody);
}
return "";
}
/**
* 构建 Authorization 头
*/
public String buildAuthorization(String method, String path, String requestBody) {
long timestamp = System.currentTimeMillis();
String signature = sign(method, path, timestamp, requestBody);
return "Octo ak=" + accessKey + ",ts=" + timestamp + ",sign=" + signature;
}
}
3.6 服务端实现要点
请求体缓存过滤器
@Component
public class RequestBodyCacheFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 包装请求,支持重复读取请求体
ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(
(HttpServletRequest) request);
chain.doFilter(wrappedRequest, response);
}
}
签名验证拦截器
@Component
public class AppAuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
// 1. 解析 Authorization 头
String authorization = request.getHeader("Authorization");
Map<String, String> authInfo = parseAuthorization(authorization);
// 2. 验证时间戳
long timestamp = Long.parseLong(authInfo.get("ts"));
if (!isTimestampValid(timestamp)) {
throw new AuthException("Timestamp expired");
}
// 3. 查询 SecretKey
String accessKey = authInfo.get("ak");
String secretKey = appService.getSecretKey(accessKey);
// 4. 计算 ContentHash
String method = request.getMethod();
String path = request.getRequestURI();
String requestBody = getRequestBody(request);
String contentHash = calculateContentHash(method, requestBody);
// 5. 计算签名并比对
String expectedSign = calculateSignature(method, path, timestamp, contentHash, secretKey);
String actualSign = authInfo.get("sign");
if (!expectedSign.equals(actualSign)) {
throw new AuthException("Invalid signature");
}
return true;
}
}
3.8 优缺点分析
| 优点 | 缺点 |
|---|
| 覆盖 POST/PUT 请求体 | 需要计算 SHA256 |
| 不需要处理 QueryString 排序 | 请求体较大时有计算开销 |
| GET/DELETE 保持简单 | 服务端需要缓存请求体 |
| 防篡改更全面 | - |
| 平衡安全与复杂度 | - |
3.9 安全评估
| 攻击类型 | 防护能力 | 说明 |
|---|
| 重放攻击 | ✅ 有效 | Timestamp ±5分钟有效期 |
| 身份伪造 | ✅ 有效 | SecretKey 只有应用知道 |
| 参数篡改 | ✅ 有效 | POST/PUT 请求体已签名 |
| 中间人攻击 | ✅ 有效 | 签名包含请求体哈希 |
3.10 适用场景
- 通用业务场景,推荐使用
- 需要防止请求体被篡改
- 对安全性有一定要求
- 需要平衡安全与开发效率
3. 方案三:阿里云风格签名
4.1 签名规则
签名字符串 = Method + "\n" +
Content-MD5 + "\n" +
Timestamp + "\n" +
URL路径 + "\n" +
CanonicalizedQueryString
签名值 = HMAC-SHA256(签名字符串, SecretKey)
4.2 字段说明
| 字段 | 说明 | 示例 |
|---|
| Method | HTTP 方法 | GET, POST, PUT, DELETE |
| Content-MD5 | 请求体 MD5(Base64编码),无请求体时为空 | 098f6bcd4621d373cade4e832627b4f6 |
| Timestamp | 毫秒级时间戳 | 1704067200000 |
| URL路径 | 请求路径(不包含查询参数) | /openapi/v1/devices |
| CanonicalizedQueryString | 规范化的查询字符串 | pageNum=1&productKey=Park001 |
4.3 CanonicalizedQueryString 构造规则
- 提取参数:获取所有 Query 参数(不包括路径参数)
- URL 编码:对参数名和参数值进行 URL 编码
- 字典排序:按编码后的参数名字典序排序
- 拼接:用
& 连接,格式为 Key1=Value1&Key2=Value2
4.4 签名示例
GET 请求
请求:GET /openapi/v1/devices?productKey=Park001&pageNum=1&pageSize=10
Timestamp:1704067200000
Content-MD5 = ""(GET 请求无请求体)
CanonicalizedQueryString 构造:
1. 参数列表:productKey=Park001, pageNum=1, pageSize=10
2. URL编码后:productKey=Park001, pageNum=1, pageSize=10
3. 字典排序:pageNum=1, pageSize=10, productKey=Park001
4. 拼接:pageNum=1&pageSize=10&productKey=Park001
签名字符串:
GET\n
\n
1704067200000\n
/openapi/v1/devices\n
pageNum=1&pageSize=10&productKey=Park001
签名值:
HMAC-SHA256(签名字符串, "sk_secret")
= "a1b2c3d4e5f6..."
POST 请求
请求:POST /openapi/v1/devices?productKey=Park001
Timestamp:1704067200000
RequestBody:{"deviceId":"Light-001","deviceName":"路灯001"}
Content-MD5 = Base64(MD5(RequestBody))
= "098f6bcd4621d373cade4e832627b4f6"
CanonicalizedQueryString = "productKey=Park001"
签名字符串:
POST\n
098f6bcd4621d373cade4e832627b4f6\n
1704067200000\n
/openapi/v1/devices\n
productKey=Park001
签名值:
HMAC-SHA256(签名字符串, "sk_secret")
= "b2c3d4e5f6a1..."
4.5 客户端实现(Java)
import org.apache.commons.codec.digest.DigestUtils;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.core.util.StrUtil;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.TreeMap;
public class AliyunStyleSignatureClient {
private final String accessKey;
private final String secretKey;
public AliyunStyleSignatureClient(String accessKey, String secretKey) {
this.accessKey = accessKey;
this.secretKey = secretKey;
}
/**
* 生成签名
*/
public String sign(String method, String path, long timestamp,
String requestBody, Map<String, String> queryParams) {
String contentMd5 = calculateContentMd5(requestBody);
String canonicalizedQueryString = buildCanonicalizedQueryString(queryParams);
String stringToSign = method + "\n" +
contentMd5 + "\n" +
timestamp + "\n" +
path + "\n" +
canonicalizedQueryString;
return SecureUtil.hmacSha256(secretKey).digestHex(stringToSign);
}
/**
* 计算 Content-MD5
*/
private String calculateContentMd5(String requestBody) {
if (StrUtil.isBlank(requestBody)) {
return "";
}
return DigestUtils.md5Hex(requestBody);
}
/**
* 构建规范化查询字符串
*/
private String buildCanonicalizedQueryString(Map<String, String> queryParams) {
if (queryParams == null || queryParams.isEmpty()) {
return "";
}
// 按 Key 字典序排序
TreeMap<String, String> sortedParams = new TreeMap<>(queryParams);
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : sortedParams.entrySet()) {
if (sb.length() > 0) {
sb.append("&");
}
sb.append(urlEncode(entry.getKey()))
.append("=")
.append(urlEncode(entry.getValue()));
}
return sb.toString();
}
/**
* URL 编码
*/
private String urlEncode(String value) {
try {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
} catch (Exception e) {
return value;
}
}
/**
* 构建 Authorization 头
*/
public String buildAuthorization(String method, String path,
String requestBody, Map<String, String> queryParams) {
long timestamp = System.currentTimeMillis();
String signature = sign(method, path, timestamp, requestBody, queryParams);
return "Octo ak=" + accessKey + ",ts=" + timestamp + ",sign=" + signature;
}
}
4.6 优缺点分析
| 优点 | 缺点 |
|---|
| 行业标准,成熟可靠 | 实现复杂 |
| 同时保护 Query 和 Body | QueryString 排序规则需明确 |
| 广泛验证 | 客户端对接成本高 |
| 适合金融/政务场景 | URL 编码规则需统一 |
4.8 安全评估
| 攻击类型 | 防护能力 | 说明 |
|---|
| 重放攻击 | ✅ 有效 | Timestamp 有效期限制 |
| 身份伪造 | ✅ 有效 | SecretKey 只有应用知道 |
| 参数篡改 | ✅ 有效 | Query + Body 均已签名 |
| 中间人攻击 | ✅ 有效 | 完整签名链 |
4.9 适用场景
- 对安全要求极高的场景
- 需要与阿里云等云服务保持一致的签名风格
- 金融、政务等敏感行业
- 有合规要求的场景
4. 方案对比
5.1 综合对比表
| 维度 | 方案一(极简) | 方案二(统一参数) | 方案三(阿里云风格) |
|---|
| 签名内容 | Method + Path + Timestamp | Method + Path + Timestamp + ContentHash | Method + MD5 + Timestamp + Path + Query |
| 复杂度 | ★☆☆ 低 | ★★☆ 中 | ★★★ 高 |
| 安全性 | ★★☆ 中 | ★★★ 高 | ★★★ 高 |
| 客户端实现 | 简单 | 中等 | 复杂 |
| 对接成本 | 低 | 中 | 高 |
| 防篡改范围 | 仅身份 | Body | Query + Body |
| 行业标准 | 否 | 否 | 是(阿里云/AWS风格) |
5.2 安全性对比
| 攻击类型 | 方案一 | 方案二 | 方案三 |
|---|
| 重放攻击 | ✅ | ✅ | ✅ |
| 身份伪造 | ✅ | ✅ | ✅ |
| Query 参数篡改 | ❌ | ❌ | ✅ |
| RequestBody 篡改 | ❌ | ✅ | ✅ |
| 中间人攻击 | ⚠️ | ✅ | ✅ |
5.3 场景推荐
| 场景 | 推荐方案 | 理由 |
|---|
| 内网快速对接 | 方案一 | 开发效率优先,内网环境风险可控 |
| 通用业务场景 | 方案二 | 平衡安全与复杂度,覆盖主要风险 |
| 金融/政务行业 | 方案三 | 合规要求,参考行业标准 |
5. 推荐方案
6.1 综合推荐:方案二(统一参数签名)
推荐理由:
- 安全性足够:覆盖 POST/PUT 请求体,防止参数篡改
- 实现简单:不涉及 QueryString 排序,只需计算 SHA256
- 对接友好:客户端只需额外计算一个哈希值
- 兼容性好:GET/DELETE 请求保持简单,POST/PUT 增强保护
- 平衡取舍:在安全性和开发效率之间取得良好平衡
6.2 迁移建议
如果当前已实现方案一,可按以下步骤迁移到方案二:
-
兼容期:服务端同时支持两种签名方式,通过请求头标识版本
Authorization: Octo v2,ak=AK_xxx,ts=1704067200000,sign=xxx
-
过渡期:新客户端使用方案二,旧客户端继续使用方案一
-
截止期:通知所有第三方在指定日期前完成迁移
-
下线期:服务端不再支持方案一
6. 实现检查清单
7.1 服务端检查清单
7.2 客户端检查清单
7.3 测试用例
7. 附录
8.1 参考资料
变更记录
| 日期 | 版本 | 变更内容 | 作者 |
|---|
| 2026-09-03 | v1.0 | 初始版本,详细记录三种签名方案 | - |