我发现了字节OpenApi接口的bug!

openapi,bug · 浏览次数 : 22

小编点评

# 生成验签代码 ```java private static String signStringEncoder(String source) { if (source == null) { return null; } StringBuilder buf = new StringBuilder(source.length()); ByteBuffer bb = UTF_8.encode(source); while (bb.hasRemaining()) { int b = bb.get() & 255; if (URLENCODER.get(b)) { buf.append((char) b); } else if (b == 32) { buf.append(\"%20\"); } else { buf.append(\"%\"); char hex1 = CONST_ENCODE.charAt(b >> 4); char hex2 = CONST_ENCODE.charAt(b & 15); buf.append(hex1); buf.append(hex2); } } return buf.toString(); } ``` ## 其他建议 1. 文档版本,建议开发者关注官方文档的更新版本。 2. 官方提供的 OpenApi 示例 demo,建议进行更深入的理解,理解各个场景的代码实现。 3. 验证验签代码,可以考虑使用第三方库进行验证,例如 Apache Commons Codec,以提升代码安全性和性能。

正文

本文记录我在对接字节旗下产品火山云旗下云游戏产品 OpenApi 接口文档时遇到的坑,希望能帮助大家(火山云旗下云游戏产品的文档坑很多,我算是从零到一都踩了一遍,特此记录,希望大家引以为鉴)。

1. 文档问题

很经典的开局一张图,对接全靠问,

image

image

这里给大家强调下,当要跟第三方产品对接时,一定要确认拿到的文档是不是最新版本。

比如我在这次对接中,第一次拿到的文档是产品给的,在业务中需要用到一个用户主动退出游戏的接口,于是我在第一份文档里面找到一个用户退出游戏的接口 RomoveUser。

image

但是当我在控制台调用此接口报错后,去群里一问才发现,对方建议我使用官网公布的最新接口文档。

官网最新文档:https://www.volcengine.com/docs/6512/143674

进入官网发现 RemoveUser 这个接口已经是历史接口了,官方建议换到 BanRoomUser 接口。

image

OK,这里算是踩到了第一个坑,文档版本不是最新。

ps:还要说一下,火山云旗下云游戏的这个 OpenApi 接口文档需要在群里联系他们开白才能看到,说实话给我的感觉很奇怪,怀疑产品是否有赶鸭子上架问题,暂且怀疑他们的目的是防止不明攻击吧。

2. OpenApi 示例 demo

第三方接口的接入一般都需要做鉴权。火山云旗下云游戏产品的 OpenApi 接口接入当然也不例外。于是我开始了第二个踩坑之旅,那就是他们给出的 OpenApi 示例 demo 的使用过于简单。

image

火山云旗下云游戏产品的 OpenApi 示例 demo 写的很简单,只提供了一个 GET 请求示例。

OpenApi 示例 demo 地址:https://github.com/volcengine/veGame

但是在我司的业务场景还是上个问题,需要一个用户主动退出游戏的接口,在火山云官网的 OpenApi 文档中我也找到了这个接口,就是上文提到的 BanRoomUser 接口。

但是在官方文档中 BanRoomUser 接口是一个 POST JSON 格式的请求。官方给出的 OpenApi 示例 demo 中并没有关于 POST JSON 请求的示例代码,所以只能靠我一个人查看他们提供的 SDK 依赖源码硬猜来写...,这就很让人头痛了。

好在我翻阅他们 SDK 源码中找到一个靠谱的 json(...) 请求方法,来完成这个 POST JSON 请求。

image

OK,说干就干,直接写好示例代码,开始发送 POST JSON 请求,

image

what f**k?什么鬼,返回了我一个 null,此时我的内心中充满了一个大大的问号。

我开始怀疑我的代码是不是写错了。但是当我经历过数次源码 debug 以及调用其他 OpenApi 接口测试并得到正确返回后,我坚定的认为我没错,这就是火山云 OpenApi 的 bug!

image

OK,说干就干,直接反馈给火山那边。

image

接着火山那边的人就联系说下午两点开会一起远程共享我的屏幕看看,OK 欣然接收,让他们见证下他们写的 bug!

...

时间来到下午两点,当我共享屏幕给字节工程师演示这个 bug 时,我的控制台打印如下,

image

woca,竟然不是 null!好在我脑袋灵活,思路清晰,瞬间想到我改了一个参数 GameId,之前返回 null 时,我传的 GameId 是一个假数据,现在我传的是一个真数据。造成了返回不一致。

OK,找到了返回正常的原因,当我把 GameId 改成假数据时,如我所愿,返回了一个 null。

image

自此,我也就在字节工程师的围观下,复现了他们的 OpenApi 接口的线上 bug。大功告成。

3. 鉴权失败

字节提供的 OpenApi 示例 demo 现在算是跑通了,但是由于我司项目一些依赖限制问题,我们不能直接引入火山云旗下云游戏产品的 SDK 依赖。所以我还得手动编写生成签名的代码。于是我开始了第三个踩坑之旅,那就是 GET 请求验签成功 POST 请求验签失败的问题。

这里先说一下,火山云提供了手动生成签名的示例代码

image

Java 生成签名的代码:https://github.com/volcengine/volc-openapi-demos/blob/main/signature/java/Sign.java

这里我也是直接把签名代码拿来即用就行,一开始接入生成签名代码非常顺利,GET 请求的 OpenApi 接口都是可以顺利调通的,但是当我调用 BanRoomUser 接口时(没错,又是这个接口,踩的三个坑都与这个接口有关),直接提示验签失败!

image

OK,开始排查为什么签名失败。

image

查看源码发现,POST JSON 请求时的 contentType 还是 application/x-www-form-urlencoded,直觉告诉我这里不对,所以改成 application/json 试试,看看控制台返回,

image

很好,还是验签失败!!!

我尽力了兄弟们,这个坑踩的我是无话可说。直接联系直接字节开发人员看下我的请求内容是哪里有问题。

在与字节开发人员一起观摩我写的代码以及生成的签名之后,大家都没找到问题所在。那没办法了,只能上服务器看接口请求日志了。

image

大家可以看出问题在哪里吗?没错我刚刚不是把 contentType 改成了 application/json 吗,为什么日志显示的 contentType 是 application/json; charset=utf-8!。

OK,到这里问题也找到了,原因是我这个项目用的 http 请求工具是 okhttp3。他自动给我拼接上去的!

那么怎么解决嘞,替换 http3 工具的话,改造成本比较大,所以我就顺势把代码的 contentType 也改成
application/json; charset=utf-8

在测试一遍,看看控制台打印。

image

OK,拿到成功响应,自此也就解决了第三个坑,POST JSON 请求时的验签不匹配问题。

最后给大家贴出手动生成验签的代码,有需要自取。

@Slf4j
public class Sign {
    private static final BitSet URLENCODER = new BitSet(256);
    private static final String CONST_ENCODE = "0123456789ABCDEF";
    public static final Charset UTF_8 = StandardCharsets.UTF_8;
    private final String region;
    private final String service;
    private final String host;
    private final String path;
    private final String ak;
    private final String sk;
    static {
        int i;
        for (i = 97; i <= 122; ++i) {
            URLENCODER.set(i);
        }

        for (i = 65; i <= 90; ++i) {
            URLENCODER.set(i);
        }

        for (i = 48; i <= 57; ++i) {
            URLENCODER.set(i);
        }
        URLENCODER.set('-');
        URLENCODER.set('_');
        URLENCODER.set('.');
        URLENCODER.set('~');
    }

    public Sign(String region, String service, String host, String path, String ak, String sk) {
        this.region = region;
        this.service = service;
        this.host = host;
        this.path = path;
        this.ak = ak;
        this.sk = sk;
    }

    public Headers calcAuthorization(String method, Map<String, String> queryList, byte[] body,
                                     Date date, String action, String version) throws Exception {
        // 请求头
        Map<String, String> headerMap = new HashMap<>();
        String contentType = "application/x-www-form-urlencoded; charset=utf-8";
        if (body == null) {
            body = new byte[0];
        } else {
            contentType = "application/json; charset=utf-8";
        }
        String xContentSha256 = hashSHA256(body);
        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
        sdf.setTimeZone(TimeZone.getTimeZone("GMT"));
        // String xDate = "20240515T061353Z";
        String xDate = sdf.format(date);
        String shortXDate = xDate.substring(0, 8);
        String signHeader = "content-type;host;x-content-sha256;x-date";

        SortedMap<String, String> realQueryList = new TreeMap<>(queryList);
        realQueryList.put("Action", action);
        realQueryList.put("Version", version);
        StringBuilder querySB = new StringBuilder();
        for (String key : realQueryList.keySet()) {
            querySB.append(signStringEncoder(key)).append("=").append(signStringEncoder(realQueryList.get(key))).append("&");
        }
        querySB.deleteCharAt(querySB.length() - 1);
        String canonicalStringBuilder = method + "\n" + path + "\n" + querySB + "\n" +
                "content-type:" + contentType + "\n" +
                "host:" + host + "\n" +
                "x-content-sha256:" + xContentSha256 + "\n" +
                "x-date:" + xDate + "\n" +
                "\n" +
                signHeader + "\n" +
                xContentSha256;

        // log.info("canonicalStringBuilder is {}", canonicalStringBuilder);
        String hashcanonicalString = hashSHA256(canonicalStringBuilder.getBytes());
        String credentialScope = shortXDate + "/" + region + "/" + service + "/request";
        String signString = "HMAC-SHA256" + "\n" + xDate + "\n" + credentialScope + "\n" + hashcanonicalString;
        // log.info("signString is {}", signString);

        byte[] signKey = genSigningSecretKeyV4(sk, shortXDate, region, service);
        String signature = HexUtil.encodeHexStr(hmacSHA256(signKey, signString));
        String auth = "HMAC-SHA256" +
                " Credential=" + ak + "/" + credentialScope +
                ", SignedHeaders=" + signHeader +
                ", Signature=" + signature;
        headerMap.put("Authorization", auth);
        headerMap.put("X-Date", xDate);
        headerMap.put("X-Content-Sha256", xContentSha256);
        headerMap.put("Host", host);
        headerMap.put("Content-Type", contentType);
        headerMap.put("User-Agent", "volc-sdk-java/v");
        headerMap.put("Accept", "application/json");
        return Headers.of(headerMap);
    }

    private static String signStringEncoder(String source) {
        if (source == null) {
            return null;
        }
        StringBuilder buf = new StringBuilder(source.length());
        ByteBuffer bb = UTF_8.encode(source);
        while (bb.hasRemaining()) {
            int b = bb.get() & 255;
            if (URLENCODER.get(b)) {
                buf.append((char) b);
            } else if (b == 32) {
                buf.append("%20");
            } else {
                buf.append("%");
                char hex1 = CONST_ENCODE.charAt(b >> 4);
                char hex2 = CONST_ENCODE.charAt(b & 15);
                buf.append(hex1);
                buf.append(hex2);
            }
        }

        return buf.toString();
    }

    public static String hashSHA256(byte[] content) throws Exception {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            // return HexFormat.of().formatHex(md.digest(content));
            return HexUtil.encodeHexStr(md.digest(content));
        } catch (Exception e) {
            throw new Exception(
                    "Unable to compute hash while signing request: "
                            + e.getMessage(), e);
        }
    }

    public static byte[] hmacSHA256(byte[] key, String content) throws Exception {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(key, "HmacSHA256"));
            return mac.doFinal(content.getBytes());
        } catch (Exception e) {
            throw new Exception(
                    "Unable to calculate a request signature: "
                            + e.getMessage(), e);
        }
    }

    private byte[] genSigningSecretKeyV4(String secretKey, String date, String region, String service) throws Exception {
        byte[] kDate = hmacSHA256((secretKey).getBytes(), date);
        byte[] kRegion = hmacSHA256(kDate, region);
        byte[] kService = hmacSHA256(kRegion, service);
        return hmacSHA256(kService, "request");
    }
}

总结

在与火山云旗下云游戏产品的 OpenApi 接口对接过程中,我总共踩了三个坑。一是文档版本不是最新,二是官方提供的 OpenApi 示例 demo 过于简单,三是官方提供的验签代码没有考虑到 POST JSON 请求场景下的 contentType 设置问题。

在这里也想给大家传个话,没有必要神话大厂,大厂也有 bug,大厂的产品也会服务中断。比如火山云旗下云游戏产品的 OpenApi 接口文档示例 demo 简陋,手动生成签名代码场景单一,覆盖不全等问题,最后就是竟然还返回了一个 null 给我!不过此次对接过程中,在我反馈 OpenApi 接口各种问题时,群里小伙伴都能及时回应以及拉群沟通查看问题解决问题的态度点个赞👍。

关注公众号【waynblog】每周分享技术干货、开源项目、实战经验、国外优质文章翻译等,您的关注将是我的更新动力!

与我发现了字节OpenApi接口的bug!相似的内容:

我发现了字节OpenApi接口的bug!

本文记录我在对接字节旗下产品火山云旗下云游戏产品 OpenApi 接口文档时遇到的坑,希望能帮助大家(火山云旗下云游戏产品的文档坑很多,我算是从零到一都踩了一遍,特此记录,希望大家引以为鉴)。 1. 文档问题 很经典的开局一张图,对接全靠问, 这里给大家强调下,当要跟第三方产品对接时,一定要确认拿到

关于领域驱动设计,大家都理解错了

翻遍整个互联网,我发现,关于领域驱动设计,大家都**理解错了**。 今天,我们尝试通过一篇文章的篇幅,给大家展示一个完全不同的视角,把“领域驱动设计”这六个字解释清楚。 ## 领域驱动设计学习资料现状 领域驱动设计的概念提出已经有20年的时间了,整个互联网充斥着大量书籍、文章和视频教程,这里我列举几

哎,被这个叫做at least once的玩意坑麻了。

你好呀,我是歪歪。 前几天遇到一个生产问题,同一个数据在数据库里面被插入了两次,导致后续处理出现了一些问题。 当时我们首先检讨了自己,没有做好幂等校验。甚至还发现了一个低级错误:对应的表,针对订单号,这个业务上具有唯一属性的字段,连唯一索引都没有加。如果加了唯一索引,也不至于出现落库两次的情况。 然

ChatGPT与码农的机会

之前一篇博客已经写了有关AI在博客编写方面的优势与对未来博客的编写方面的思考。这篇文档我继续分享我在开发中的一个案例和相关的感想。 事件还原 我发现ChatGPT也可以帮助我编写OData,于是我也利用GPT帮助我编程。 OData如何将filter与apply字段联合使用?答案如下: GET /o

FastJson不成想还有个版本2啊:序列化大字符串报错

# 背景 发现陷入了一个怪圈,写文章的话,感觉只有大bug或比较值得写的内容才会写,每次一写就是几千字,争取写得透彻一些,但这样,我也挺费时间,读者也未必有这么多时间看。 我想着,日常遇到的小bug、平时工作中的一些小的心得体会,都还是可以写写,这样也才是最贴近咱们作为一线开发生活的,也不必非得是个

2022 ICPC 杭州站

gym 知乎 尝试先读题而不是写缺省源感觉不太好 E 一头雾水。F 是签到就先上去写了,结果读错题交了个样例都没过的代码,小改了一下就过了。G 不太会做。zsy 把 M 丢给我想了一下 然后 gjk 把 D 过了。看榜发现 K 过了很多人,需要快速判断比较两个字符串等价于比较哪两个字符,反应了一下才

【转帖】纳尼,mysqldump导出的数据居然少了40万?

0、导读 用mysqldump备份数据时,加上 -w 条件选项过滤部分数据,发现导出结果比实际少了40万,什么情况? 本文约1500字,阅读时间约5分钟。 1、问题 我的朋友小文前几天遇到一个怪事,他用mysqldump备份数据时,加上了 -w 选项过滤部分数据,发现导出的数据比实际上少了40万。

[转帖]20191022-从Jenkins NativeOOM到Java8内存

我把老掉牙的Jenkins升级了,它跑了几天好好的;后来我有一个python脚本使用JenkinsAPI 0.3.9每隔2.5分钟发送约300余get请求,结果过了3天,它就挂了;当我开两个脚本时,40.5小时就挂了。(可以通过搜索Jenkins日志/var/log/jenkins/* 中字符Jen

前后端分离项目(十一):实现"删"功能(前后端)

好家伙,本篇介绍如何实现"删"功能 来看效果, 数据库 (自然是没什么毛病) "增"搞定了,其实"删"非常简单 (我不会告诉你我是为了水一篇博客才把他们两个分开写,嘿嘿) 逻辑简洁明了: 首先,看见你要删除的数据,点"删除", 随后,①拿到当前这条数据的Id,向后台发请求网络, 然后,②后端删除该字

[转帖]解决jmeter请求响应结果乱码的问题

如下图所示,请求百度接口的时候,发现返回的信息里面中文是乱码 这个时候我们只需要改一下jmeter里的配置文件,设置响应结果的字符编码为UTF-8就行了。 进入jmeter安装目录/bin中,找到jmeter.properties这个文件,windows用文本编辑器打开,我是mac的,直接vim编辑