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

参数

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

  • 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来实现。