数据签名可以用来校验数据的合法性。
我在使用 Dubbo 的过程中,遇到了以下一些校验请求/响应来源合法性的方案:
在请求的 attatchment 中写入一个约定的key-value键值对,服务方发现有该键值对则认为合法,否则认为不合法。
该方案,毫无意义。
对于部分字段,使用双方约定的密钥,生成签名(一般是用一个哈希算法,也可以用公私钥的方式进行签名,这里不具体讲)。
例如约定每个请求中都带上一个 appId 和时间戳字段,签名计算方式如下:
签名 = MD5(appId值 + 时间戳 + 密钥值)
该方案问题如下:
将业务数据请求和响应字段固定化,增加字符串类型的 extra 字段用于以后的扩展,这种情况下 extra 中一般是个 JSON 格式的字符串。
将所有的字段按照约定的顺序组装在一起,使用双方约定的密钥,生成签名。
该方案的问题:
通过反射的形式拿到所有的字段,对所有的字段值按照字段名排序,然后组装在一起,使用双方约定的密钥,生成签名。
该方案的问题:
上面的几个方案都或多或少有些问题。为了避免上面那些问题,我尝试设计了下面这一方案。
网上暂未找到类似的方案,如有类似,说明正好想到一块了。
实现一个简单可靠的签名机制有两个思路:
思路1: 让 Dubbo 自身支持对请求/响应数据的签名验证。在 Dubbo 中,网络传输的是 Java 对象序列化后的二进制数据,可以对这个二进制数据进行签名。这其实是对 Dubbo 协议的修改。
思路2: 把 Dubbo 协议类比网络中的tcp/ip 层,业务代码是 http 层。将签名放在 http 层。上面介绍的几个方案其实都是这个思路。
思路1需要对 Dubbo 本身的实现进行侵入性修改,本文不考虑。
我们看下思路2如何实现。
@Data
public class SignedDataContainer implements Serializable {
// 调用方标识,会分配密钥
private String appId;
// 时间戳(单位秒)
private Long timestamp;
// 业务数据(使用 json 序列化)
private String bizData;
// 签名
private String sign;
}
bizData 存放业务数据,业务上要扩展,直接在 bizData 中扩展即可。
SignedDataContainer 类本身被设计为不能扩展,也不需要扩展,
在 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×tamp=%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 字段之外的所有字段都参与了签名,所以数据合法性是能保证的。
到目前,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<String>),则需要使用 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);
}
// 省略其他方法
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 分钟之内。
SignedDataContainer<Long> signedDataContainer = new SignedDataContainer<>();
signedDataContainer.storeBizData(123L);
Long bizData = signedDataContainer.fetchBizData(Long.class);
Assertions.assertEquals(123L, bizData);
SignedDataContainer<Void> signedDataContainer = new SignedDataContainer<>();
signedDataContainer.storeBizData(null);
Object obj = signedDataContainer.fetchBizData(Void.class);
Assertions.assertNull(obj);
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));
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 。
( 本文完 )