基于Spring做请求参数的加解密/签名校验

之前做的一个对外网关项目,安全需要所以得对参数进行加密校验

参数

接口交互约定了三个参数:

  • timestamp(请求时间戳)
  • data(实际请求数据,经过加密得到的字符串)
  • sign(以HMAC-SHA1算法对参数的签名)

请求方式

接口统一使用json来进行交互,请求的Content-Type统一为application/json

方案

1.需引入依赖 commons-codec

1
2
3
4
5
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.11</version>
</dependency>

2.根据需要先提供一个工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@UtilityClass
@CommonsLog
public class EncryptUtils {

public static String HMAC_KEY = "abcd1234abcd";

/**
* 以HMAC-SHA1算法对字符串toEnc进行签名
*/
public static String hmacSha1(String toEnc, String key) {
HmacUtils hmacUtils = new HmacUtils(HmacAlgorithms.HMAC_SHA_1, key);

return hmacUtils.hmacHex(toEnc);
}

public static String urlEncode(String str) {
return urlEncode(str, StandardCharsets.UTF_8);
}

public static String urlEncode(String str, Charset charset) {
try {
return URLEncoder.encode(str, charset.toString());
} catch (UnsupportedEncodingException e) {
log.info("URL encode 失败", e);
return null;
}
}
}

3.声明一个空接口,在下面的参数处理器中,仅处理实现了Encryptable接口的参数类对象

1
2
public interface Encryptable {
}

声明加密后的参数对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper = false)
public class EncryptModel {

/**
* 时间戳(毫秒)
*/
private Long timestamp;

/**
* json加密后的字符串
*/
private String data;

/**
* 参数签名
*/
private String signature;
}

4.通过自定义 RequestBodyAdviceAdapter 来实现对参数的自动解密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
@RequiredArgsConstructor
@ControllerAdvice(assignableTypes = {OpenApiController.class})
@CommonsLog
public class RequestDecryptAdvice extends RequestBodyAdviceAdapter {

// 请求时间戳检验的时间限制
private static Integer TIME_LIMIT = 5;

@Override
public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
Class<?> clazz = (Class) targetType;

return Encryptable.class.isAssignableFrom(clazz);
}

@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
Class<?> clazz = (Class) targetType;
if (Encryptable.class.isAssignableFrom(clazz)) {
log.info("校验OpenApi请求");
ByteArrayInputStream inputStream = convert(inputMessage.getBody());

log.info("OpenApi请求校验成功");
return new MappingJacksonInputMessage(inputStream, inputMessage.getHeaders());
} else {
return super.beforeBodyRead(inputMessage, parameter, targetType, converterType);
}
}

/**
* 校验签名
*
* @param body 请求体
* @return 转换后的请求参数体
*/
private ByteArrayInputStream convert(InputStream body) throws IOException {
String content = StreamUtils.copyToString(body, StandardCharsets.UTF_8);
EncryptModel encryptModel = JsonUtil.readValue(content, EncryptModel.class);

log.info("OpenApi请求校验, 校验参数: " + JsonUtil.toJson(encryptModel));
checkEncryptModel(encryptModel);

Long timestamp = encryptModel.getTimestamp();
log.info("OpenApi请求校验, 校验时间戳: " + timestamp);
checkTimestamp(timestamp);

String dataFrom64 = encryptModel.getData();
byte[] data = Base64.decodeBase64(dataFrom64);
encryptModel.setData(new String(data));
String rawSign = getRowSign(encryptModel);
log.info("构建参数, 待签名参数:" + rawSign);
String sign = EncryptUtils.hmacSha1(rawSign, HMAC_KEY);
log.info("OpenApi请求校验, 校验签名: " + sign);

if (StringKit.ne(sign, encryptModel.getSignature())) {
throw new OpenApiException("签名校验失败");
}

return new ByteArrayInputStream(data);
}

/**
* 拼接参数,生成待签名的字符串
* @param encryptModel 请求参数
* @return 待签名字符串
*/
private String getRowSign(EncryptModel encryptModel) {
String data = encryptModel.getData();
JsonNode dataNode = JsonUtil.readValue(data, JsonNode.class);

List<String> paramList = new ArrayList<>();

paramList.add("timestamp=" + EncryptUtils.urlEncode(encryptModel.getTimestamp().toString()));

dataNode.fieldNames().forEachRemaining(key -> {
String value = dataNode.get(key).asText(null);

if (StringUtils.isBlank(value)) {
return;
}

String encode = EncryptUtils.urlEncode(value);

paramList.add(key + "=" + encode);
});

paramList.sort(String::compareTo);

return String.join("&", paramList);
}

/**
* 校验加密请求参数
* @param encryptModel 参数
*/
private void checkEncryptModel(EncryptModel encryptModel) {
if (encryptModel == null) {
throw new OpenApiException("参数不能为空");
}

if (encryptModel.getTimestamp() == null) {
throw new OpenApiException("缺少参数: timestamp");
}

if (encryptModel.getData() == null) {
throw new OpenApiException("缺少参数: data");
}

if (encryptModel.getSignature() == null) {
throw new OpenApiException("缺少参数: signature");
}
}

/**
* 校验请求时间戳
* @param timestamp 时间戳
*/
private void checkTimestamp(Long timestamp) {
Instant now = Instant.now().atZone(ZoneId.systemDefault()).toInstant();
Instant time = Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toInstant();
if (now.isBefore(time) || now.plus(TIME_LIMIT, ChronoUnit.MINUTES).isBefore(time)) {
throw new OpenApiException("无权访问");
}
}

}

测试

对接口测试可以通过对controller写单元测试,这是另一部分下次再说。

小结

如果只是对参数进行参数校验,防止参数被篡改,那其实只需要在收到请求后,对收到的请求参数以同样的方式签名,最终判断签名是否一致,即可防止请求被劫持导致的安全问题。
而Spring对于请求的处理机制,提供了很方便的方式去统一处理请求参数,通过自定义RequestBodyAdvice即可对参数进行统一的解析并校验签名。同样,如果需要对返回值进行统一的加密处理,也可以通过自定义的ResponseBodyAdvice来实现。