背景
数据签名可以用来校验数据的合法性。
我在使用 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×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 字段之外的所有字段都参与了签名,所以数据合法性是能保证的。
步骤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<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);
}
// 省略其他方法
步骤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"));