本文档介绍服务端通过HTTP或者HTTPS协议请求菜鸟IoT平台的签名机制

简介

对于每一次HTTP或者HTTPS协议请求,菜鸟IoT平台会根据请求中的签名信息验证访问请求者身份,并对请求数据做校验。具体使用AccessKeyID和AccessKeySecret对称加密验证实现。AccessKeyID和AccessKeySecret暂时联系木升获取,请您提供账号id信息:

  • AccessKeyID:访问者身份
  • AccessKeySecret:加密签名字符串和服务器端验证签名字符串的密钥,必须严格保密谨防泄露。

    签名流程

    服务端进行AK/SK签名并发送请求的流程如下:
  1. 构造规范化请求字符串。将待发送的请求内容(请求路径、查询参数、用于签名的消息头、请求体)按照约定规则进行组装。确保对同一个请求,服务端和菜鸟IoT平台能够获得相同的签名结果。
  2. 使用“规范化请求字符串”以及“签名时间”构造待签名字符串
  3. 使用“AK/SK”和“待签名字符串”计算签名
  4. 使用“AK”、“生成的签名”等信息,添加Authorization签名信息到请求头

上述流程的具体实现方式如下:

构造规范化请求字符串

生成规范化请求字符串(CanonicalRequest)的伪代码如下:

  1. CanonicalRequest =
  2. HTTPRequestMethod + '\n' +
  3. CanonicalURI + '\n' +
  4. CanonicalQueryString + '\n' +
  5. CanonicalHeaders + '\n' +
  6. SignedHeaders + '\n' +
  7. HexEncode(Hash(RequestPayload))

我们以请求菜鸟IoT平台、查询某节点下的设备列表接口为例,说明规范化请求字符串的构造步骤。您也可以使用如下示例验证您的签名算法是否能得到正确的签名结果。
假设原始请求如下,其中“X-Cws-Date”请求头为构造规范化请求字符串时需要携带的公共参数

  1. GET https://service.example.com/api/group/INNTER_TEST_PRE/LEMO/devices/meta?search=&pageNo=1&pageSize=10
  2. HOST: service.example.com
  3. Content-Type:application/json
  4. X-Cws-Date: 20211220T051630Z

依次在规范化请求字符串中添加如下内容:

步骤1:添加HTTP请求方法(HTTPRequestMethod),以换行符结束:
  1. GET

步骤2: 添加规范化URI参数(CanonicalURI),以换行符结束:
  1. GET
  2. /api/group/INNTER_TEST_PRE/LEMO/devices/meta/
  • 规范化URI,即请求的资源路径,是URI的绝对路径部分,即URI中host尾部到查询参数(如有)的问号字符 (“?”) 之间所有内容。
  • 格式:

    • 根据RFC 3986标准化URI路径,移除冗余和相对路径部分。
    • 如果URI路径不以”/“结尾,则在尾部添加”/“。
    • 如果绝对路径为空,则规范化URI参数为”/“。
      步骤3: 添加规范化查询参数字符串(CanonicalQueryString),以换行符结束:
      1. GET
      2. /api/group/INNTER_TEST_PRE/LEMO/devices/meta/
      3. pageNo=1&pageSize=10&search=
  • 如果没有查询参数,则CanonicalQueryString为空字符串,即在规范化请求字符串中表现为空行

  • 如有查询参数,根据以下规则对每个参数名和值进行URI编码:
    • RFC 3986定义中的非预留字符不进行URI编码,这些不编码的字符包括:A-Z、a-z、0-9、-、_、.和~。
    • 其余字符编码成%XY的格式,其中XY是字符对应ASCII码的16进制。示例:半角双引号对应%22
    • 扩展的UTF-8字符,编码成%XY%ZA…的格式。
    • 空格编码成%20,而不是加号+。
  • 对于每个参数,使用“等号(=)”连接编码后的请求参数和参数取值。如果没有参数值,则以空字符串代替,但不能省略“=”,例如“pageSize=10&search=”,其中第二个参数search的值为空。
  • 按照忽略大小写的字典序以升序顺序对请求参数名进行排序。例如,以小写字母a开头的参数名排在以大写字母F开头的参数名之前。
  • 以排序后的第一个参数名开始,构造规范查询字符串。两两参数间以“&”作为分隔符,最后一个参数后无“&”。
    步骤4: 添加规范化消息头(CanonicalHeaders),以换行符结束:
    ```shell GET /api/group/INNTER_TEST_PRE/LEMO/devices/meta/ pageNo=1&pageSize=10&search= content-type:application/json host:service.example.com x-cws-date:20211220T051630Z
  1. - 规范化消息头包含所有需要签名的请求头。消息头必须包含X-Cws-Date(参见[公共参数](https://www.yuque.com/cniot/gfd79e/vlpgg6#glv7m)),用于校验签名时间,格式为[ISO8601](https://www.iso.org/iso-8601-date-and-time-format.html)标准的**UTC**时间格式:YYYYMMDDTHHMMSSZ。
  2. > ⚠️注意:服务端需注意本地时间与时钟服务器的同步,避免请求消息头X-Cws-Date的值出现较大误差。菜鸟IoT平台除了校验时间格式外,还会校验该时间值与收到请求的时间差,如果时间差超过**15分钟**,菜鸟IoT平台将拒绝请求。
  3. - 格式:CanonicalHeaders由多个请求消息头共同组成,生成CanonicalHeaders的伪代码如下:
  4. ```shell
  5. CanonicalHeaders =
  6. CanonicalHeadersEntry0 + CanonicalHeadersEntry1 + ... + CanonicalHeadersEntryN

上式中,

CanonicalHeadersEntry =
Lowercase(HeaderName) + ':' + Trimall(HeaderValue) + '\n'
  • 上述伪代码中,Lowercase()表示将所有字符转换为小写字母的函数,Trimall()表示删除值前后的多余空格的函数。即生成CanonicalHeaders时,需要将消息头名称转换为小写形式,并删除前导空格和尾随空格。
  • 按照忽略大小写的字典序,对消息头名称进行升序排序。
  • 由于最后一个请求消息头也会携带一个换行符。叠加规范中CanonicalHeaders自身携带的换行符,因此会出现一个空行。
    步骤5: 添加待签消息头声明(SignedHeaders),以换行符结束:
    以下消息头声明表示,有三个消息头参与签名:Content-Type、Host、X-Cws-Date ```shell GET /api/group/INNTER_TEST_PRE/LEMO/devices/meta/ pageNo=1&pageSize=10&search= content-type:application/json host:service.example.com x-cws-date:20211220T051630Z

content-type;host;x-cws-date


- 通过添加此消息头,向菜鸟IoT平台告知请求中哪些消息头被用于签名,反之,也就是在对请求鉴权时菜鸟IoT平台可以忽略哪些消息头。
> ⚠️注意:X-Cws-date必须作为用于签名的消息头。

- 生成SignedHeaders的伪代码如下:
```shell
SignedHeaders =
Lowercase(HeaderName0) + ';' + Lowercase(HeaderName1) + ";" + ... + Lowercase(HeaderNameN)
  • 上述伪代码中,Lowercase()函数将消息头中的所有字符转换为小写字母。
  • 按照忽略大小写的字典序,对消息头名称进行升序排序。
    步骤6: 使用SHA256哈希函数计算请求体(RequestPayload)的Base-16编码哈希值,至此,规范化请求字符串(CanonicalRequest)构造结束:
    ```shell GET /api/group/INNTER_TEST_PRE/LEMO/devices/meta/ pageNo=1&pageSize=10&search= content-type:application/json host:service.example.com x-cws-date:20211220T051630Z

content-type;host;x-cws-date e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855


- 计算请求体(**RequestPayload**)16进制哈希值的伪代码如下:
```shell
HexEncode(Hash(RequestPayload))
  • 上述伪代码中,Hash表示通过SHA-256算法生成消息摘要的函数。HexEncode表示以小写字母形式返回摘要的Base-16编码的函数。例如,HexEncode(“m”) 返回值为“6d”而不是“6D”。输入的每一个字节都表示为两个十六进制字符。
  • 若请求体为空(如本示例),即RequestPayload==null,此时,令RequestPayload=””(空字符串)计算请求体的Base-16编码哈希值。

    步骤7: 对构造好的规范化请求字符串做哈希处理,获得HashedCanonicalRequest,伪代码如下:
    HashedCanonicalRequest = 
    HexEncode(Hash(CanonicalRequest))
    
  • 上述伪代码中,HexEncode()函数和Hash()函数的含义与步骤6中相同。

  • 在本示例中,哈希处理后的规范化请求字符串(HashedCanonicalRequest)为:

    a9e21a3ed7bc21bb73e9aa833795e6154248a978d60247ee2b2d7d02aa12c210
    

    构造待签名字符串

    将哈希处理的规范化请求字符串(HashedCanonicalRequest),签名算法(Algorithm)以及签名时间(RequestDateTime),按照如下伪代码中的格式构造待签名字符串(StringToSign):

    StringToSign =
      Algorithm + \n +
      RequestDateTime + \n +
      HashedCanonicalRequest
    

    上述伪代码中的参数说明:

  • Algorithm

用于计算签名的算法,菜鸟IoT平台用于签名的算法名称为CWS-HMAC-SHA256

  • RequestDateTime

签名时间戳。与请求消息头中的X-Cws-Date的值相同,格式参见公共参数

  • HashedCanonicalRequest

以SHA256算法生成的Base16编码的规范化请求字符串的信息摘要,参见链接
在本示例中,待签名字符串为:

CWS-HMAC-SHA256
20211220T051630Z
a9e21a3ed7bc21bb73e9aa833795e6154248a978d60247ee2b2d7d02aa12c210

计算签名

AccessKeySecret作为密钥,根据HmacSHA256算法,对待签名字符串StringToSign)计算签名signature),伪代码如下:

signature = 
HexEncode(HMAC(AccessKeySecret, StringToSign))

上述伪代码中,HexEncode表示以小写字母形式返回摘要的Base-16编码的函数,HMAC()表示基于HmacSHA256算法哈希运算。
假设AccessKeySecretIyqloJkd0wMFHzJsItp83gACCC3gca,则计算所得的签名为:

75a5033478badfe10b444d05d056612cca479af2b552fae4bf8efa4221329baa

添加签名信息到请求头

签名算法Algorithm),AccessKey待签消息头声明SignedHeaders)以及签名signature),按照如下伪代码中的格式构造认证请求头Authorization),伪代码如下:

Algorithm Access=Accesskey, SignedHeaders=SignedHeaders, Signature=signature

在本示例中,认证请求头为:

CWS-HMAC-SHA256 Access=KlHDjAhYJ8AjXI3tBE4sIJIc, SignedHeaders=content-type;host;x-cws-date, Signature=75a5033478badfe10b444d05d056612cca479af2b552fae4bf8efa4221329baa

至此,ak/sk请求签名完成,CURL请求样例如下:

curl --request GET 'https://service.example.com/api/group/INNTER_TEST_PRE/LEMO/devices/meta?search=&pageNo=1&pageSize=10'
--header 'Content-Type: application/json'
--header 'HOST: service.example.com'
--header 'X-Cws-Date: 20211220T051630Z'
--header 'Authorization: CWS-HMAC-SHA256 Access=KlHDjAhYJ8AjXI3tBE4sIJIc, SignedHeaders=content-type;host;x-cws-date, Signature=75a5033478badfe10b444d05d056612cca479af2b552fae4bf8efa4221329baa'

JAVA示例代码

以下将为您介绍用于对请求进行ak/sk签名的示例代码。本示例不需要依赖第三方的库包,可以直接使用。

测试用例

如下测试用例所得Authorization认证头为: CWS-HMAC-SHA256 Access=KlHDjAhYJ8AjXI3tBE4sIJIc, SignedHeaders=content-type;host;x-cws-date, Signature=75a5033478badfe10b444d05d056612cca479af2b552fae4bf8efa4221329baa

/**
 * 原始请求:
 * curl --request GET 'https://service.example.com/api/group/INNTER_TEST_PRE/LEMO/devices/meta?search=&pageNo=1&pageSize=10'
 * --header 'Host: service.example.com'
 * --header 'Content-Type: application/json'
 * --header 'X-Cws-Date: 20211220T051630Z'
 */
public static void main(String[] args) {
    String accessKey = "KlHDjAhYJ8AjXI3tBE4sIJIc";
    String accessSecret = "IyqloJkd0wMFHzJsItp83gACCC3gca";
    String method = "GET";
    String path = "/api/group/INNTER_TEST_PRE/LEMO/devices/meta";
    Map<String, List<String>> queryStringParams = new Hashtable();
    queryStringParams.put("search", Arrays.asList(""));
    queryStringParams.put("pageNo", Arrays.asList("1"));
    queryStringParams.put("pageSize", Arrays.asList("10"));
    Map<String, String> headers = new Hashtable();
    headers.put("Content-Type", "application/json");
    headers.put("X-Cws-Date", "20211220T051630Z");
    headers.put("Host", "service.example.com");
    String body = null;

    AkSkSigner signer = new AkSkSigner();
    System.out.println(signer.sign(accessKey, accessSecret, method, path, queryStringParams, headers, body));
}

AkSkSigner.class(用于计算Authorization请求头):

public class AkSkSigner {
    public static final String LINE_SEPARATOR = "\n";
    public static final String CWS_SIGNING_ALGORITHM = "CWS-HMAC-SHA256";
    public static final String X_CWS_DATE = "X-Cws-Date";
    public static final String X_CWS_CONTENT_SHA256 = "x-cws-content-sha256";

    public AkSkSigner() {
    }

    // 计算Authorization请求头
    public String sign(String accessKey, String accessSecret, String method, String path, Map<String, List<String>> queryStringParams, Map<String, String> headers, String body) {
        // 获取签名时间
        String signerDate = this.getHeader(headers, X_CWS_DATE);
        // 计算请求体的hash值
        String contentSha256 = this.calculateContentHash(headers, body);
        // 对请求头按忽略大小写的字母序升序排列
        String[] signedHeaders = this.getSignedHeaders(headers);
        // 构造规范化请求字符串
        String canonicalRequest = this.createCanonicalRequest(method, path, queryStringParams, headers, signedHeaders, contentSha256);
        // 构造待签名字符串
        String stringToSign = this.createStringToSign(canonicalRequest, signerDate);
        // 计算签名
        byte[] signingKey = this.deriveSigningKey(accessSecret);
        byte[] signature = this.computeSignature(stringToSign, signingKey);
        // 生成Authorization请求头
        return this.buildAuthorizationHeader(signedHeaders, signature, accessKey);
    }

    protected String getCanonicalizedResourcePath(String resourcePath) {
        if (resourcePath != null && !resourcePath.isEmpty()) {
            try {
                resourcePath = (new URI(resourcePath)).getPath();
            } catch (URISyntaxException e) {
                return resourcePath;
            }

            String value = HttpUtils.urlEncode(resourcePath, true);
            if (!value.startsWith("/")) {
                value = "/".concat(value);
            }

            if (!value.endsWith("/")) {
                value = value.concat("/");
            }

            return value;
        } else {
            return "/";
        }
    }

    protected String getCanonicalizedQueryString(Map<String, List<String>> parameters) {
        SortedMap<String, List<String>> sorted = new TreeMap();
        Iterator queryNames = parameters.entrySet().iterator();

        while(queryNames.hasNext()) {
            Map.Entry<String, List<String>> entry = (Map.Entry)queryNames.next();
            String encodedParamName = HttpUtils.urlEncode((String)entry.getKey(), false);
            List<String> paramValues = (List)entry.getValue();
            List<String> encodedValues = new ArrayList(paramValues.size());
            Iterator values = paramValues.iterator();

            while(values.hasNext()) {
                String value = (String)values.next();
                encodedValues.add(HttpUtils.urlEncode(value, false));
            }

            Collections.sort(encodedValues);
            sorted.put(encodedParamName, encodedValues);
        }

        StringBuilder result = new StringBuilder();
        Iterator sortedParameters = sorted.entrySet().iterator();

        while(sortedParameters.hasNext()) {
            Map.Entry<String, List<String>> entry = (Map.Entry)sortedParameters.next();

            String value;
            for(Iterator values = ((List)entry.getValue()).iterator(); values.hasNext(); result.append(entry.getKey()).append("=").append(value)) {
                value = (String)values.next();
                if (result.length() > 0) {
                    result.append("&");
                }
            }
        }

        return result.toString();
    }

    protected String createCanonicalRequest(String method, String path, Map<String, List<String>> queryStringParams, Map<String, String> headers, String[] signedHeaders, String contentSha256) {
        StringBuilder canonicalRequestBuilder = new StringBuilder(method);
        canonicalRequestBuilder.append(LINE_SEPARATOR).append(this.getCanonicalizedResourcePath(path)).append(LINE_SEPARATOR).append(this.getCanonicalizedQueryString(queryStringParams)).append(LINE_SEPARATOR).append(this.getCanonicalizedHeaderString(headers, signedHeaders)).append(LINE_SEPARATOR).append(this.getSignedHeadersString(signedHeaders)).append(LINE_SEPARATOR).append(contentSha256);
        String canonicalRequest = canonicalRequestBuilder.toString();
        return canonicalRequest;
    }

    protected String createStringToSign(String canonicalRequest, String signerDate) {
        StringBuilder stringToSignBuilder = new StringBuilder(CWS_SIGNING_ALGORITHM);
        stringToSignBuilder.append(LINE_SEPARATOR).append(signerDate).append(LINE_SEPARATOR).append(toHex(this.hash(canonicalRequest)));
        String stringToSign = stringToSignBuilder.toString();
        return stringToSign;
    }

    private final byte[] deriveSigningKey(String secret) {
        return secret.getBytes(StandardCharsets.UTF_8);
    }

    protected byte[] sign(byte[] data, byte[] key, SigningAlgorithm algorithm) {
        try {
            Mac mac = Mac.getInstance(algorithm.toString());
            mac.init(new SecretKeySpec(key, algorithm.toString()));
            return mac.doFinal(data);
        } catch (InvalidKeyException | NoSuchAlgorithmException e) {
            return null;
        }
    }

    protected final byte[] computeSignature(String stringToSign, byte[] signingKey) {
        return this.sign(stringToSign.getBytes(StandardCharsets.UTF_8), signingKey, SigningAlgorithm.HmacSHA256);
    }

    private String buildAuthorizationHeader(String[] signedHeaders, byte[] signature, String accessKey) {
        String credential = "Access=" + accessKey;
        String signerHeaders = "SignedHeaders=" + this.getSignedHeadersString(signedHeaders);
        String signatureHeader = "Signature=" + toHex(signature);
        StringBuilder authHeaderBuilder = new StringBuilder();
        authHeaderBuilder.append(CWS_SIGNING_ALGORITHM).append(" ").append(credential).append(", ").append(signerHeaders).append(", ").append(signatureHeader);
        return authHeaderBuilder.toString();
    }

    protected String[] getSignedHeaders(Map<String, String> headers) {
        String[] signedHeaders = headers.keySet().toArray(new String[0]);
        Arrays.sort(signedHeaders, String.CASE_INSENSITIVE_ORDER);
        return signedHeaders;
    }

    protected String getCanonicalizedHeaderString(Map<String, String> headers, String[] signedHeaders) {
        Map<String, String> requestHeaders = headers;
        StringBuilder buffer = new StringBuilder();
        int len = signedHeaders.length;

        for(int i = 0; i < len; ++i) {
            String header = signedHeaders[i];
            String key = header.toLowerCase();
            String value = requestHeaders.get(header);
            buffer.append(key).append(":");
            if (value != null) {
                buffer.append(value.trim());
            }

            buffer.append(LINE_SEPARATOR);
        }

        return buffer.toString();
    }

    protected String getSignedHeadersString(String[] signedHeaders) {
        StringBuilder buffer = new StringBuilder();
        int len = signedHeaders.length;

        for(int i = 0; i < len; ++i) {
            String header = signedHeaders[i];
            if (buffer.length() > 0) {
                buffer.append(";");
            }

            buffer.append(header.toLowerCase());
        }

        return buffer.toString();
    }

    protected String getHeader(Map<String, String> headers, String header) {
        if (headers == null || headers.isEmpty()) {
            return null;
        } else {
            Iterator names = headers.keySet().iterator();

            String key;
            do {
                if (!names.hasNext()) {
                    return null;
                }

                key = (String) names.next();
            } while(!header.equalsIgnoreCase(key));

            return headers.get(key);
        }
    }

    protected String calculateContentHash(Map<String, String> headers, String body) {
        String content_sha256 = this.getHeader(headers, X_CWS_CONTENT_SHA256);
        if (body == null) {
            body = "";
        }
        return content_sha256 != null ? content_sha256 : toHex(this.hash(body));
    }

    public byte[] hash(String text) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            md.update(text.getBytes(StandardCharsets.UTF_8));
            return md.digest();
        } catch (NoSuchAlgorithmException e) {
            return null;
        }
    }

    public enum SigningAlgorithm {
        HmacSHA256;

        private SigningAlgorithm() {
        }
    }

    public static String toHex(byte[] data) {
        StringBuilder sb = new StringBuilder(data.length * 2);
        int len = data.length;

        for(int i = 0; i < len; ++i) {
            byte b = data[i];
            String hex = Integer.toHexString(b);
            if (hex.length() == 1) {
                sb.append("0");
            } else if (hex.length() == 8) {
                hex = hex.substring(6);
            }

            sb.append(hex);
        }

        return sb.toString().toLowerCase(Locale.getDefault());
    }

    public static class HttpUtils {
        private static final String DEFAULT_ENCODING = "UTF-8";
        private static final Pattern ENCODED_CHARACTERS_PATTERN;

        public HttpUtils() {
        }

        public static String urlEncode(String value, boolean path) {
            if (value == null) {
                return "";
            } else {
                try {
                    String encoded = URLEncoder.encode(value, DEFAULT_ENCODING);
                    Matcher matcher = ENCODED_CHARACTERS_PATTERN.matcher(encoded);

                    StringBuffer buffer;
                    String replacement;
                    for(buffer = new StringBuffer(encoded.length()); matcher.find(); matcher.appendReplacement(buffer, replacement)) {
                        replacement = matcher.group(0);
                        if ("+".equals(replacement)) {
                            replacement = "%20";
                        } else if ("*".equals(replacement)) {
                            replacement = "%2A";
                        } else if ("%7E".equals(replacement)) {
                            replacement = "~";
                        } else if (path && "%2F".equals(replacement)) {
                            replacement = "/";
                        }
                    }

                    matcher.appendTail(buffer);
                    return buffer.toString();
                } catch (UnsupportedEncodingException e) {
                    throw new RuntimeException(e);
                }
            }
        }

        static {
            StringBuilder pattern = new StringBuilder();
            pattern.append(Pattern.quote("+")).append("|").append(Pattern.quote("*")).append("|").append(Pattern.quote("%7E")).append("|").append(Pattern.quote("%2F"));
            ENCODED_CHARACTERS_PATTERN = Pattern.compile(pattern.toString());
        }
    }
}

生成当前时间的UTC时间戳

public static String generateTimestamp() {
    return CwsDateUtil.formatCwsDate(new Date(System.currentTimeMillis()));
}

public class CwsDateUtil {
    private static final String CWS_DATE_FORMAT = "yyyyMMdd'T'HHmmss'Z'";

    public static String formatCwsDate(Date date) {
        return getCwsDateFormat().format(date);
    }

    public static Date parseCwsDate(String dateString) throws ParseException {
        try {
            return getCwsDateFormat().parse(dateString);
        } catch (ParseException e) {
            e.printStackTrace();
            throw e;
        }
    }

    private static DateFormat getCwsDateFormat() {
        SimpleDateFormat df = new SimpleDateFormat(CWS_DATE_FORMAT);
        df.setTimeZone(TimeZone.getTimeZone("UTC"));
        return df;
    }
}