技术要点


技术要点

传输协议

采用HTTPS进行消息传输,客户端应当对服务商端的证书做验证,但当前暂不要求服务端对客户端证书进行验证,通过签名认证机制保障安全性。

HTTPS的安全加密协议应当使用TLS V1.3。TLS协议中包含CBC模式加密套件,该类套件在密码协议中使用常常会因为实现不当引入漏洞而被攻击者利用分析明文,并且已被HTTP 2列入黑名单,本规范禁止TLS协议中使用CBC模式(SSL2.0、SSL3.0、TLS1.0、TLS1.1、TLS1.2中均禁止使用)。

  • 华为云数字化差旅系统提供的是HTTPS服务,拥有权威证书中心颁发的有效数字证书,可以保证从外部IT系统向华为云数字化差旅服务器请求时的传输过程是可信安全的。
  • 当需要访问外部IT系统时,建议外部IT系统也提供HTTPS服务,使用权威证书中心颁发的数字证书,保证传输过程安全性。
  • 消息可以使用压缩传输方式,支持的压缩算法为:gzip, deflate

报文格式

遵循HTTP规范,交互过程中涉及URL、消息头、消息体。

  • URL路径中只允许ASCII可见字符,不允许出现中文。参数中的非ASCII编码应当使用URL Encoding。建议采用https://www.xxx.xxx/napi/xxopen in new window这种格式的URL。

  • 消息体采用标准JSON格式,UTF-8编码,整条消息大小不应当超过1M字节。

  • HTTP头中增加Authorization,用于传递认证信息,包括请求消息和响应消息。

  • 消息格式:

    HTTP请求内容如下:

    {HttpMethod} {HttpUri} HTTP/1.1
    …其他HTTP消息头…
    Authorization: {认证信息}
    …其他HTTP消息头…
    {HttpBody}
    

    HTTP响应内如下:

    HTTP/1.1 200 OK
    …其他HTTP消息头…
    Authorization: {认证信息}
    …其他HTTP消息头…
    {HttpBody}
    

    与差旅平台交互时,尽可能遵循标准HTTP协议,只携带必要的消息头,否则可能会触发平台的安全防御机制导致消息被拦截。

签名机制

提供两种签名机制,开发前需要向差旅平台申请配置密钥认证方式,从而获得自己的 authId(鉴权对象ID), accessKey(请求密钥})、secretKey (密钥)

方式一:密钥认证(只适用于测试环境)

方式二:签名认证

  1. 密钥认证
  • 开发前需要向差旅平台申请配置密钥认证方式,从而获得自己的 authId(鉴权对象ID), accessKey(请求密钥})

  • Authorization生成策略:type=APPKEY, authId={authId}, accessKey={accessKey}

  • 报文示例

    request:

    POST napi/enterprise/department/query HTTP/1.1
    Host: openapi.hwht.com
    Content-Type: application/json
    Authorization: type=APPKEY, authId=123423, accessKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    Content-Length: xx
    {"name":"value","key":"value"}
    
    

    response:

    HTTP/1.1 200 OK
    Content-Type: application/json
    Authorization: type=APPKEY, authId=123423, accessKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    Content-Length: xx
    {"ResultCode":0,"Description": "Success"}
    
  1. 签名认证
  • 开发前需要向差旅平台申请配置密钥认证方式,从而获得自己的 authId(鉴权对象ID), accessKey(请求密钥})、secretKey (密钥)

  • Authorization生成策略:type=AKSK-HMAC-SHA256, authId ={authId} ,accessKey={accessKey}, date={requestTime}, bodySignature={bodySignature}, signature={signature}

  • requestTime:发送时的时间,格式:yyyyMMdd'T'HHmmss'Z' ,相差不允许超过20分钟

  • bodySignature: HexEncode(SHA256({body内容})),如果body为空或者body内容大于10M,直接给空字符串,不需要摘要计算

  • signature:

    待签名的字符串组装如下:

    HTTPRequestMethod + '\n ' + 
    CanonicalURI + '\n' +
    RequestTime + '\n ' +
    AccessKey + '\n' +
    CanonicalQueryString + '\n' +
    BodySignature
    
    

    HexEncode(HMAC-SHA256({secretKey }, {待签名的字符串}))

    备注:

    1. CanonicalQueryString 如果没有,则必须保留该行,给空字符
    2. HexEncode表示以小写字母形式返回摘要的Base-16编码的函数。例如,HexEncode("m") 返回值为“6d”而不是“6D”。输入的每一个字节都表示为两个十六进制字符。
    3. 如果服务端验签失败:报错返回响应信息为 "signature error, server string to sign: XXX",其中XXX表示待签名字符串。
    4. 如果服务端与客户端的待签名字符串一致,则需要检查用于签名计算的secretKey是否正确。
    • java版参考代码
    @Component
    @Slf4j
    public class OpenApiNextUtil {
  
        private static DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT);
        @Autowired
        private RestTemplate template;
  
  
        @Bean
        @ConditionalOnMissingBean(RestTemplate.class)
        public RestTemplate restTemplate() {
            return new RestTemplate(generateHttpsRequestFactory());
        }
  
        private HttpComponentsClientHttpRequestFactory generateHttpsRequestFactory() {
            try {
                TrustStrategy acceptingTrustStrategy = (x509Certificates, authType) -> true;
                SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(null, acceptingTrustStrategy).build();
                SSLConnectionSocketFactory connectionSocketFactory =
                        new SSLConnectionSocketFactory(sslContext, new NoopHostnameVerifier());
  
                HttpClientBuilder httpClientBuilder = HttpClients.custom();
                httpClientBuilder.setSSLSocketFactory(connectionSocketFactory);
                CloseableHttpClient httpClient = httpClientBuilder.build();
                HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
                factory.setHttpClient(httpClient);
                factory.setConnectTimeout(10 * 1000);
                factory.setReadTimeout(30 * 1000);
                return factory;
            } catch (Exception e) {
                log.error("创建HttpsRestTemplate失败", e);
                throw new RuntimeException("创建HttpsRestTemplate失败", e);
            }
        }
        
        /**
         * 执行器
         *
         * @param uri         完成的请求地址
         * @param contextPath 上下文(不存在通过上下文转发的请求,不需要填写,格式为“/contentPath”)
         * @param authId 差旅平台办法的用户标识
         * @param accessKey 差旅平台办法的 appkey
         * @param secretKey 差旅平台办法的密钥
         * @param body 请求体
         * @param httpHeaders header
         * @return 返回体
         */
        public String execute(URI uri, String contextPath, String authId, String accessKey, String secretKey, String body, HttpHeaders httpHeaders) {
            String path = uri.getPath();
            if (StringUtils.hasText(contextPath)) {
                path = path.replace(contextPath, "");
            }
            String date = LocalDateTime.now().format(formatter);
  
            String bodySignature = StringUtils.isEmpty(body) ? "" : getSha256Str(body);
            System.out.println("bodySignature = " + bodySignature);
            System.out.println("==================");
  
            HttpMethod method = HttpMethod.POST;
  
            StringBuilder sb = new StringBuilder();
            sb.append(method.name()).append("\n");
            sb.append(path).append("\n");
            sb.append(date).append("\n");
            sb.append(accessKey).append("\n");
            sb.append(StringUtils.hasText(uri.getQuery()) ? uri.getQuery() : "").append("\n");
            sb.append(bodySignature);
            System.out.println("sb.toString() = \n" + sb);
            System.out.println("==================");
  
            String signature = signBySha256(secretKey, sb.toString());
            System.out.println("signature = " + signature);
            System.out.println("==================");
  
            String authorization = "type=AKSK-HMAC-SHA256, authId=%s, accessKey=%s, date=%s, bodySignature=%s,signature=%s";
            String format = String.format(authorization, authId, accessKey, date, bodySignature, signature);
            System.out.println("Authorization = " + format);
  
  
            MultiValueMap<String, String> headers = null;
            if (httpHeaders == null) {
                headers = new HttpHeaders();
            } else {
                headers = httpHeaders;
            }
            headers.add("Authorization", format);
            headers.add("Content-Type", "application/json");
            RequestEntity<?> entity = new RequestEntity<>(body, headers, method, uri);
  
            try {
                ResponseEntity<String> exchange = template.exchange(entity, String.class);
                if (!HttpStatus.OK.equals(exchange.getStatusCode())) {
                    log.warn("request fail,resp={}", exchange);
                    return "";
                }
                return exchange.getBody();
            } catch (Exception e) {
                log.error("request Exception", e);
                return "";
            }
        }
  
        private static final String ALGORITHM = "HmacSHA256";
  
        private static String signBySha256(String key, String src) {
            try {
                Mac sha256_HMAC = Mac.getInstance(ALGORITHM);
                SecretKeySpec secret_key = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), ALGORITHM);
                sha256_HMAC.init(secret_key);
                byte[] bytes = sha256_HMAC.doFinal(src.getBytes(StandardCharsets.UTF_8));
                return Hex.encodeHexString(bytes, true);
            } catch (Exception e) {
                log.error("HMacSha256Util sign error,src={}", src);
                return null;
            }
        }
  
        private static String getSha256Str(String str) {
            MessageDigest messageDigest;
            String encodeStr = null;
            try {
                messageDigest = MessageDigest.getInstance("SHA-256");
                messageDigest.update(str.getBytes(StandardCharsets.UTF_8));
                encodeStr = Hex.encodeHexString((messageDigest.digest()));
            } catch (NoSuchAlgorithmException e) {
                log.error("没有该摘要算法", e);
            }
            return encodeStr;
        }
    }
    public class AkSkTest {
      @Autowired
      private OpenApiNextUtil openApiNextUtil;

      @Test
      public void sdkTest() throws Exception {
          String url = "https://openapi-falpha.hwht.com/napi/enterprise/department/detail?q=123&p=456";
          //        String url = "http://127.0.0.1:9999/napi/enterprise/department/list";
          URI uri = URI.create(url);
          String body = "{\"msgId\":\"7\",\"corpCode\":\"WELINK_3F69EE286E50450C99DE226D7F07EBD5\",\"pageIndex\":\"1\",\"pageSize\":20}";
          //        String body = "{\"orderId\": \"F0001\"}";
          String secretKey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
          String authId = "test_ak_sk";
          String accessKey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";

          
          String resp = openApiNextUtil.execute(uri, null, authId, accessKey, secretKey, body, null);
          System.out.println(resp);
      }
    }
  • 报文示例

    request

    POST https://openapi-falpha.hwht.com/napi/enterprise/department/detail?q=123&p=456 HTTP/1.1
    Host: openapi.hwht.com
    Authorization: type=AKSK-HMAC-SHA256, authId=test_ak_sk, accessKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx, date=20240703T135445Z, bodySignature=76b83bfe3263b75ded07caf16c0ccebfaf94f3a628c8a829dcf9936b9d121e24,signature=e7a140a91f5ed2a51e5e14a3cba4e78aff0987c52946e0bd3fe73b4c1e24e75c
    Content-Type: application/json
    
    {"msgId":"7","corpCode":"WELINK_3F69EE286E50450C99DE226D7F07EBD5","pageIndex":"1","pageSize":20}
    
    

    response

    HTTP/1.1 200 OK
    Content-Type: application/json
    Authorization: type=AKSK-HMAC-SHA256,authId=test_ak_sk,accessKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx,date=20240703T135445Z,bodySignature=ddbfe94e0c8aa2734c061ab54cd48331d92f771b8b9ef681da8699c45c1d2e78,signature=80e2421019c72c44afebace06b4cd6bc3962845b32ba943f2b522191ad08adde
    Content-Length: 67
    {"ResultCode":"840001070","Description":"部门编号不能为空"}
    

IP白名单

客户IT系统和华为云数字化差旅系统之间通过服务器点对点互访,互相把对端的IP地址列入防火墙放通白名单,因此需要双方交换服务器IP地址,并进行白名单开通。并在出现IP变动时及时通知,否则消息可能会被防火墙拦截。

接口授权

华为云数字化差旅开放平台提供的所有接口需要根据商务合同逐一进行授权。因此在对接时需要明确要调用的接口范围,有变化时及时变更签约协议。

接口时效性

请求消息中带有时间戳,用于检查消息的时效性。为防止服务器的时间有差异,及提供消息重发机制,因此允许在20分钟的时间范围内。

接口幂等性

幂等性是数学中的一个概念,表达的是N次变换与1次变换的结果相同。消息的幂等设计借鉴这个意义,指的是消息多次调用处理结果一致,不会造成异常。基于消息或事件的应用里面,因为网络的不可靠,出现投递消息时消息丢失了,或者消费成功的返回消息丢失了,这都会导致服务被动处理相同的消息/事件多次。这种消息的不可靠不可避免,只有服务保证了幂等,才能使业务不出现异常。

本接口规范通过消息的唯一ID和消息状态来保证接口的幂等性:

要求每种需要支持幂等的请求消息由客户方生成唯一的消息ID,服务方在回应消息时需要带有同样的消息ID。服务方和客户方都需要记录请求的状态,对请求支持重发机制和判重机制。

  1. 每个请求消息都要有回应,客户方收到回应后才能认为该消息已发送成功。回应包括成功的回应和失败的回应。

  2. 在请求消息中包含唯一ID字段,用于判断消息的唯一性。回应消息中返回请求消息中的ID值用于判断回应消息是否正确。

  3. 对于查询类的请求(如订单查询),不需要支持幂等,客户方等待超时后,可再次进行查询。

  4. 对于非查询类的请求(如数据同步类),如果客户方等待回应超时,则认为消息发送失败,需要进行消息重发。重发的时间间隔、次数需要可配置,如隔20秒进行第一次重发,隔5分钟进行第二次重发,第二次重发还失败,则记录日志,不再进行业务处理中的重发。重发总时长不能超过接口的时效性时差。

    服务方支持幂等,需要支持接受到重复消息时的判重,并根据自己对该消息的处理状态分别进行处理:如果已经对该消息成功处理了,则不再处理,直接返回上次的返回结果;如果还没有对该消息进行处理,则进行处理,并返回处理结果。

  5. 对于非查询类的请求,实现缓存发送机制。如,服务方不可用时,则客户方暂停消息发送,全部缓存起来。当服务方可用时,再设置客户方成发送,则客户方将累积未发送的消息逐条发送给服务方。本机制同样适用于长时间的网络中断。

调用入口

正式环境:https://openapi.hwht.com/napi/open in new window

测试环境:https://openapi-uat.hwht.com/napi/open in new window

完整示例

外部系统向差旅平台发请求,消息如下:

secretKey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
accessKey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  • 假设未认证前的消息
POST https://openapi-falpha.hwht.com/napi/enterprise/department/detail?q=123&p=456 HTTP/1.1
Host: openapi.hwht.com
Content-Type: application/json

{"msgId":"7","corpCode":"WELINK_3F69EE286E50450C99DE226D7F07EBD5","pageIndex":"1","pageSize":20}
  • body摘要
76b83bfe3263b75ded07caf16c0ccebfaf94f3a628c8a829dcf9936b9d121e24
  • 此时待签名字符串为
POST
/napi/enterprise/department/detail
20240703T135445Z
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
q=123&p=456
76b83bfe3263b75ded07caf16c0ccebfaf94f3a628c8a829dcf9936b9d121e24
  • 签名串
e7a140a91f5ed2a51e5e14a3cba4e78aff0987c52946e0bd3fe73b4c1e24e75c

使加密算法配合分配的AESkey对上述字符串进行加密,结果为:7a537e2f23….2e16

  • 最终请求
POST https://openapi-falpha.hwht.com/napi/enterprise/department/detail?q=123&p=456 HTTP/1.1
Host: openapi.hwht.com
Authorization: type=AKSK-HMAC-SHA256, authId=test_ak_sk, accessKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx, date=20240703T135445Z, bodySignature=76b83bfe3263b75ded07caf16c0ccebfaf94f3a628c8a829dcf9936b9d121e24,signature=e7a140a91f5ed2a51e5e14a3cba4e78aff0987c52946e0bd3fe73b4c1e24e75c
Content-Type: application/json

{"msgId":"7","corpCode":"WELINK_3F69EE286E50450C99DE226D7F07EBD5","pageIndex":"1","pageSize":20}
  • 平台回复消息如下
HTTP/1.1 200 OK
Content-Type: application/json
Authorization: type=AKSK-HMAC-SHA256,authId=test_ak_sk,accessKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx,date=20240703T135445Z,bodySignature=ddbfe94e0c8aa2734c061ab54cd48331d92f771b8b9ef681da8699c45c1d2e78,signature=80e2421019c72c44afebace06b4cd6bc3962845b32ba943f2b522191ad08adde
Content-Length: 67
{"ResultCode":"840001070","Description":"部门编号不能为空"}