一个简单可靠的 Dubbo 请求 响应数据签名方案


#软件架构与思考#


背景

数据签名可以用来校验数据的合法性。

我在使用 Dubbo 的过程中,遇到了以下一些校验请求/响应来源合法性的方案:

方案1

在请求的 attatchment 中写入一个约定的key-value键值对,服务方发现有该键值对则认为合法,否则认为不合法。

该方案,毫无意义。

方案2

对于部分字段,使用双方约定的密钥,生成签名(一般是用一个哈希算法,也可以用公私钥的方式进行签名,这里不具体讲)。

例如约定每个请求中都带上一个 appId 和时间戳字段,签名计算方式如下:

签名 = MD5(appId值 + 时间戳 + 密钥值)

该方案问题如下:

  • 服务端没有限制时间戳只能是最近一段时间内的,所以无法提前拦截不合理的请求重放。
  • 签名没有包含业务字段,所以坏人截获到一个请求后,修改业务字段后,重新发起请求,服务端校验签名会通过。

方案3

将业务数据请求和响应字段固定化,增加字符串类型的 extra 字段用于以后的扩展,这种情况下 extra 中一般是个 JSON 格式的字符串。

将所有的字段按照约定的顺序组装在一起,使用双方约定的密钥,生成签名。

该方案的问题:

  • 字段固化,扩展性差。虽然增加了 extra 字段以保证扩展性,但是 extra 的维护会非常麻烦。

方案4

通过反射的形式拿到所有的字段,对所有的字段值按照字段名排序,然后组装在一起,使用双方约定的密钥,生成签名。

该方案的问题:

  • 组装是以字符串的形式进行组装,这意味着客户端和服务端对于相同字段转字符串的方式必须相同。这对于复杂类型有一定的挑战。比如某个字段是 UserInfo 类型,内部有 name、mail 等字段,客户端和服务端要必须保证对该对象转字符串的结果是相同的。最理想的方案是复杂类型内部的字段再进行一次排序。这增加了实现的复杂度。
  • 该方案中,若某字段值为 null,则字段名和字段值都不要用于签名,否则在新增字段的场景中服务上线会有问题。因为在Dubbo 中对协议新增字段后,服务端和客户端无法同时上线,无论是服务端先上线还是客户端先上线,都会导致服务端验签名失败。
  • 上面这一点,导致了新增字段不能有默认值。

上面的几个方案都或多或少有些问题。为了避免上面那些问题,我尝试设计了下面这一方案。

网上暂未找到类似的方案,如有类似,说明正好想到一块了。

一个简单可靠的方案

实现一个简单可靠的签名机制有两个思路:

思路1: 让 Dubbo 自身支持对请求/响应数据的签名验证。在 Dubbo 中,网络传输的是 Java 对象序列化后的二进制数据,可以对这个二进制数据进行签名。这其实是对 Dubbo 协议的修改。

思路2: 把 Dubbo 协议类比网络中的tcp/ip 层,业务代码是 http 层。将签名放在 http 层。上面介绍的几个方案其实都是这个思路。

思路1需要对 Dubbo 本身的实现进行侵入性修改,本文不考虑。

我们看下思路2如何实现。

步骤1:设计基础字段

@Data
public class SignedDataContainer implements Serializable {

    // 调用方标识,会分配密钥
    private String appId;
    // 时间戳(单位秒)
    private Long timestamp;
    // 业务数据(使用 json 序列化)
    private String bizData;
    // 签名
    private String sign;

}

bizData 存放业务数据,业务上要扩展,直接在 bizData 中扩展即可。

SignedDataContainer 类本身被设计为不能扩展,也不需要扩展,

步骤2: 设计生成签名、验证签名的方式

在 SignedDataContainer 中新增以下方法:

/**
 * 生成 timestamp、sign
 *
 * @param secretKey 密钥
 */
public void generateSign(String secretKey) {
    this.timestamp = System.currentTimeMillis()/1000;
    this.sign = buildSign(secretKey);
}

/**
 * 校验签名
 *
 * @param secretKey 密钥
 * @return 校验结果
 */
public boolean checkSign(String secretKey) {
    return Objects.equals(this.sign, buildSign(secretKey));
}

/**
 * 生成签名
 * @param secretKey 密钥
 * @return 签名
 */
private String buildSign(String secretKey) {
    String data = String.format("appId=%s&timestamp=%s&bizData=%s&secretKey=%s",
            appId == null ? "" : appId,
            timestamp,
            bizData,
            secretKey == null ? "" : secretKey);
    // 使用 sha256 算法签名,基于 Guava 库 Hashing 类
    return Hashing.sha256().newHasher().putString(data, Charsets.UTF_8).hash().toString();
}

SignedDataContainer 类中 sign 字段之外的所有字段都参与了签名,所以数据合法性是能保证的。

步骤3: 让 bizData 更好用

到目前,bizData 被设计成 JSON 字符串,这对开发并不友好。如果能直接反序列化为实际的业务类,那么该方案更容易被人接受。

我们将业务类用泛型进行抽象,基于 gson 库提供序列化和反序列化方法:

public class SignedDataContainer<T> implements Serializable {

    // 调用方标识,会分配密钥
    private String appId;
    // 时间戳(单位秒)
    private Long timestamp;
    // 业务数据(使用 json 序列化)
    private String bizData;
    // 签名
    private String sign;

    /**
     * 将 bizData 反序列化为业务类对象
     *
     * @param clz 业务类类型
     * @return 业务类对象
     */
    public T fetchBizData(Class<T> clz) {
        Gson gson = new Gson();
        if (bizData == null) {
            return null;
        }
        return gson.fromJson(this.bizData, clz);
    }

    /**
     * 将 bizData 反序列化为业务类对象
     *
     * @param typeToken 业务类本身若有泛型(例如List&lt;String&gt;),则需要使用 TypeToken 机制反序列化
     * @return 业务类对象
     */
    public T fetchBizData(TypeToken<T> typeToken) {
        Gson gson = new Gson();
        if (bizData == null) {
            return null;
        }
        return gson.fromJson(this.bizData, typeToken.getType());
    }

    /**
     * 将业务类对象序列化为 bizData
     * @param obj
     */
    public void storeBizData(T obj) {
        Gson gson = new Gson();
        if (obj == null) {
            return;
        }
        this.bizData = gson.toJson(obj);
    }

    // 省略其他方法

步骤4: 增强字段校验

Dubbo 支持对请求类中的 @NotNull 等注解修饰的字段进行校验。现在我们将业务数据以字符串的形式放到 bizData 中了,那么可以将 bizData 反序列化为业务类,然后对业务类进行字段校验。

可以自定义一个校验工具类:

import org.hibernate.validator.messageinterpolation.ParameterMessageInterpolator;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import java.util.Set;

public class ValidatorUtil {

     private final static Validator validator = Validation.byDefaultProvider()
             .configure()
             .messageInterpolator(new ParameterMessageInterpolator())
             .buildValidatorFactory()
             .getValidator();

    /**
     * 对象内部的字段校验
     *
     * @param obj
     * @param <T>
     */
     public static <T> void validate(T obj) {
         if (obj == null) {
             throw new RuntimeException("不能为null");
         }
         Set<ConstraintViolation<T>> constraintViolations = validator.validate(obj);
         for(ConstraintViolation<T> item : constraintViolations) {
             throw new RuntimeException(item.getMessage());
         }
     }

}

使用示例:

import lombok.Data;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import javax.validation.constraints.NotBlank;

public class ValidatorUtilTest {

    @Data
    public static class UserInfo {
        @NotBlank(message = "name 不能为空")
        private String name;
    }


    @Test
    public void test_validate() {
        UserInfo userInfo = new UserInfo();

        // 因为 name 没有值,校验不通过,抛异常
        try {
            ValidatorUtil.validate(userInfo);
            Assertions.fail();
        } catch (RuntimeException ex) {
            Assertions.assertEquals("name 不能为空", ex.getMessage());
        }

        // 通过设值,让校验通过
        userInfo.setName("李白");
        ValidatorUtil.validate(userInfo);
    }

}

步骤5:增加时间校验,防止重放

这个比较简单,加一下范围限制就行。例如限制客户端传入的时间戳只能在服务端当前时间前后 5 分钟之内。

使用示例

示例1

SignedDataContainer<Long> signedDataContainer = new SignedDataContainer<>();
signedDataContainer.storeBizData(123L);
Long bizData = signedDataContainer.fetchBizData(Long.class);
Assertions.assertEquals(123L, bizData);

示例2

SignedDataContainer<Void> signedDataContainer = new SignedDataContainer<>();
signedDataContainer.storeBizData(null);
Object obj = signedDataContainer.fetchBizData(Void.class);
Assertions.assertNull(obj);

示例3

SignedDataContainer<List<String>> signedDataContainer = new SignedDataContainer<>();
signedDataContainer.storeBizData(Lists.newArrayList("123", "456"));
List<String> bizData = signedDataContainer.fetchBizData(new TypeToken<List<String>>(){});
System.out.println(bizData);
Assertions.assertEquals(2, bizData.size());
Assertions.assertEquals("123", bizData.get(0));
Assertions.assertEquals("456", bizData.get(1));

示例4

String appId = "appId-test";
String secretKey = "sk-test";
SignedDataContainer<String> signedDataContainer = new SignedDataContainer<>();
signedDataContainer.setAppId(appId);
signedDataContainer.storeBizData("hello");
signedDataContainer.generateSign(secretKey);
Assertions.assertTrue(signedDataContainer.checkSign(secretKey));
Assertions.assertFalse(signedDataContainer.checkSign(secretKey + "111"));

更多基于单测的示例和 Dubbo 示例,见 https://github.com/letiantian/dubbo-sign-demo


( 本文完 )