耕码记

开放 API 签名方案设计

2026/09/03
1
0

开放 API 签名方案设计

本文档详细记录开放 API 签名规则的多种方案设计,供技术选型参考。


1. 方案一:极简签名

1.1 签名规则

签名字符串 = Method + "\n" + URL路径 + "\n" + Timestamp
签名值 = HMAC-SHA256(签名字符串, SecretKey)

2.2 请求头格式

Authorization: Octo ak=AK_xxx,ts=1704067200000,sign=a1b2c3d4e5f6...
字段说明
akAccessKey(应用标识)
tsTimestamp(毫秒级时间戳)
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空字符串 ""
POSTSHA256(RequestBody JSON字符串)
PUTSHA256(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 字段说明

字段说明示例
MethodHTTP 方法GET, POST, PUT, DELETE
Content-MD5请求体 MD5(Base64编码),无请求体时为空098f6bcd4621d373cade4e832627b4f6
Timestamp毫秒级时间戳1704067200000
URL路径请求路径(不包含查询参数)/openapi/v1/devices
CanonicalizedQueryString规范化的查询字符串pageNum=1&productKey=Park001

4.3 CanonicalizedQueryString 构造规则

  1. 提取参数:获取所有 Query 参数(不包括路径参数)
  2. URL 编码:对参数名和参数值进行 URL 编码
  3. 字典排序:按编码后的参数名字典序排序
  4. 拼接:用 & 连接,格式为 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 和 BodyQueryString 排序规则需明确
广泛验证客户端对接成本高
适合金融/政务场景URL 编码规则需统一

4.8 安全评估

攻击类型防护能力说明
重放攻击✅ 有效Timestamp 有效期限制
身份伪造✅ 有效SecretKey 只有应用知道
参数篡改✅ 有效Query + Body 均已签名
中间人攻击✅ 有效完整签名链

4.9 适用场景

  • 对安全要求极高的场景
  • 需要与阿里云等云服务保持一致的签名风格
  • 金融、政务等敏感行业
  • 有合规要求的场景

4. 方案对比

5.1 综合对比表

维度方案一(极简)方案二(统一参数)方案三(阿里云风格)
签名内容Method + Path + TimestampMethod + Path + Timestamp + ContentHashMethod + MD5 + Timestamp + Path + Query
复杂度★☆☆ 低★★☆ 中★★★ 高
安全性★★☆ 中★★★ 高★★★ 高
客户端实现简单中等复杂
对接成本
防篡改范围仅身份BodyQuery + Body
行业标准是(阿里云/AWS风格)

5.2 安全性对比

攻击类型方案一方案二方案三
重放攻击
身份伪造
Query 参数篡改
RequestBody 篡改
中间人攻击⚠️

5.3 场景推荐

场景推荐方案理由
内网快速对接方案一开发效率优先,内网环境风险可控
通用业务场景方案二平衡安全与复杂度,覆盖主要风险
金融/政务行业方案三合规要求,参考行业标准

5. 推荐方案

6.1 综合推荐:方案二(统一参数签名)

推荐理由

  1. 安全性足够:覆盖 POST/PUT 请求体,防止参数篡改
  2. 实现简单:不涉及 QueryString 排序,只需计算 SHA256
  3. 对接友好:客户端只需额外计算一个哈希值
  4. 兼容性好:GET/DELETE 请求保持简单,POST/PUT 增强保护
  5. 平衡取舍:在安全性和开发效率之间取得良好平衡

6.2 迁移建议

如果当前已实现方案一,可按以下步骤迁移到方案二:

  1. 兼容期:服务端同时支持两种签名方式,通过请求头标识版本

    Authorization: Octo v2,ak=AK_xxx,ts=1704067200000,sign=xxx
    
  2. 过渡期:新客户端使用方案二,旧客户端继续使用方案一

  3. 截止期:通知所有第三方在指定日期前完成迁移

  4. 下线期:服务端不再支持方案一


6. 实现检查清单

7.1 服务端检查清单

  • 请求体缓存过滤器(支持重复读取)
  • Authorization 头解析
  • Timestamp 有效期验证(±5分钟)
  • SecretKey 查询与解密
  • ContentHash 计算
  • 签名计算与比对
  • 认证失败异常处理
  • 日志记录

7.2 客户端检查清单

  • 签名工具类实现
  • Timestamp 生成
  • ContentHash 计算(方案二/三)
  • QueryString 排序(方案三)
  • URL 编码(方案三)
  • Authorization 头构建
  • 请求发送封装

7.3 测试用例

  • GET 请求签名验证
  • POST 请求签名验证(含请求体)
  • PUT 请求签名验证(含请求体)
  • DELETE 请求签名验证
  • Timestamp 过期拒绝
  • 签名错误拒绝
  • 应用不存在拒绝
  • 应用已禁用拒绝

7. 附录

8.1 参考资料


变更记录

日期版本变更内容作者
2026-09-03v1.0初始版本,详细记录三种签名方案-