首页  ·  知识 ·  架构设计
安全优雅的RESTfulAPI签名实现方案
网友  博客园  综合  编辑:Gillian   图片来源:网络
在为第三方系统提供接口的时候,肯定要考虑接口数据的安全问题,比如数据是否被篡改,数据是否已经过时,数据是否可以重复提交等问题。其中我认为最终要的还是数据是否被篡改

1、接口签名的必要性

在为第三方系统提供接口的时候,肯定要考虑接口数据的安全问题,比如数据是否被篡改,数据是否已经过时,数据是否可以重复提交等问题。其中我认为最终要的还是数据是否被篡改。在此分享一下我的关于接口签名的实践方案。

2、项目中签名方案痛点

  • 每个接口有各自的签名方案,不统一,维护成本较高。

  • 没有对消息实体进行签名,无法避免数据被篡改。

  • 无法避免数据重复提交。

3、签名及验证流程

签名流程说明

4、签名规则

  • 线下分配appid和appsecret,针对不同的调用方分配不同的appid和appsecret。

  • 加入timestamp(时间戳),10分钟内数据有效。

  • 加入流水号nonce(防止重复提交),至少为10位。针对查询接口,流水号只用于日志落地,便于后期日志核查。 针对办理类接口需校验流水号在有效期内的唯一性,以避免重复请求。

  • 加入signature,所有数据的签名信息。
    其中appid、timestamp、nonce、signature这四个字段放入请求头中。

5、签名生成

5.1、数据部分

  • Path:按照path中的顺序将所有value进行拼接

  • Query:按照key字典序排序,将所有key=value进行拼接

  • Form:按照key字典序排序,将所有key=value进行拼接

  • Body:

Json: 按照key字典序排序,将所有key=value进行拼接(例如{"a":"a","c":"c","b":{"e":"e"}} => a=a^_^b=e=e^_^c=c)String: 整个字符串作为一个拼接

如果存在多种数据形式,则按照path、query、form、body的顺序进行再拼接,得到所有数据的拼接值。
上述拼接的值记作 Y。

5.2、请求头部分

X="appid=xxxnonce=xxxtimestamp=xxx"

5.3、生成签名

最终拼接值=XY
最后将最终拼接值按照如下方法进行加密得到签名(signature)。

signature=org.apache.commons.codec.digest.HmacUtils.hmacSha256Hex(app secret, 拼接的值);

6、签名算法实现

6.1、指定哪些接口或者哪些实体需要签名

@Target({TYPE, METHOD})@Retention(RUNTIME)@Documentedpublic @interface Signature {
   String ORDER_SORT = "ORDER_SORT";//按照order值排序
   String ALPHA_SORT = "ALPHA_SORT";//字典序排序
   boolean resubmit() default true;//允许重复请求
   String sort() default Signature.ALPHA_SORT;
}

6.2、指定哪些字段需要签名

@Target({FIELD})@Retention(RUNTIME)@Documentedpublic @interface SignatureField {    //签名顺序
   int order() default 0;    //字段name自定义值
   String customName() default "";    //字段value自定义值
   String customValue() default "";
}

6.3、签名核心算法(SignatureUtils)

public static String toSplice(Object object) {    if (Objects.isNull(object)) {        return StringUtils.EMPTY;
   }    if (isAnnotated(object.getClass(), Signature.class)) {
       Signature sg = findAnnotation(object.getClass(), Signature.class);        switch (sg.sort()) {            case Signature.ALPHA_SORT:                return alphaSignature(object);            case Signature.ORDER_SORT:                return orderSignature(object);            default:                return alphaSignature(object);
       }
   }    return toString(object);
}private static String alphaSignature(Object object) {
   StringBuilder result = new StringBuilder();
   Map<String, String> map = new TreeMap<>();    for (Field field : getAllFields(object.getClass())) {        if (field.isAnnotationPresent(SignatureField.class)) {
           field.setAccessible(true);            try {                if (isAnnotated(field.getType(), Signature.class)) {                    if (!Objects.isNull(field.get(object))) {
                       map.put(field.getName(), toSplice(field.get(object)));
                   }
               } else {
                   SignatureField sgf = field.getAnnotation(SignatureField.class);                    if (StringUtils.isNotEmpty(sgf.customValue()) || !Objects.isNull(field.get(object))) {
                       map.put(StringUtils.isNotBlank(sgf.customName()) ? sgf.customName() : field.getName()
                               , StringUtils.isNotEmpty(sgf.customValue()) ? sgf.customValue() : toString(field.get(object)));
                   }
               }
           } catch (Exception e) {
               LOGGER.error("签名拼接(alphaSignature)异常", e);
           }
       }
   }    for (Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator(); iterator.hasNext(); ) {
       Map.Entry<String, String> entry = iterator.next();
       result.append(entry.getKey()).append("=").append(entry.getValue());        if (iterator.hasNext()) {
           result.append(DELIMETER);
       }
   }    return result.toString();
}private static String toString(Object object) {
   Class<?> type = object.getClass();    if (BeanUtils.isSimpleProperty(type)) {        return object.toString();
   }    if (type.isArray()) {
       StringBuilder sb = new StringBuilder();        for (int i = 0; i < Array.getLength(object); ++i) {
           sb.append(toSplice(Array.get(object, i)));
       }        return sb.toString();
   }    if (ClassUtils.isAssignable(Collection.class, type)) {
       StringBuilder sb = new StringBuilder();        for (Iterator<?> iterator = ((Collection<?>) object).iterator(); iterator.hasNext(); ) {
           sb.append(toSplice(iterator.next()));            if (iterator.hasNext()) {
               sb.append(DELIMETER);
           }
       }        return sb.toString();
   }    if (ClassUtils.isAssignable(Map.class, type)) {
       StringBuilder sb = new StringBuilder();        for (Iterator<? extends Map.Entry<String, ?>> iterator = ((Map<String, ?>) object).entrySet().iterator(); iterator.hasNext(); ) {
           Map.Entry<String, ?> entry = iterator.next();            if (Objects.isNull(entry.getValue())) {                continue;
           }
           sb.append(entry.getKey()).append("=").append(toSplice(entry.getValue()));            if (iterator.hasNext()) {
               sb.append(DELIMETER);
           }
       }        return sb.toString();
   }    return NOT_FOUND;
}

  • toSplice方法首先判断对象是否注有@Signature注解,如果有则获取签名的排序规则(key值字典序排序或者指定order的值进行排序),比如排序规则是Signature.ALPHA_SORT(字典序)会调用alphaSignature方法生成key=value的拼接串;如果对象没有@Signature注解,该对象类型可能是数组、者集合类等,则调用toString方法生成key=value的拼接串。

  • alphaSignature方法通过反射获取到对象的所有Field属性,需要判断两种情况:(1)获取该Field属性对应的Class信息,如果Class信息含有@Signature注解,则调用toSplice方法生成key=value的拼接串;(2)该Field属性含有@SignatureField注解,调用toString方法生成key=value的拼接串。

  • toString方法针对array, collection, simple property, map类型的数据做处理。其中如果对象是java的simple property类型,直接调用对象的toString方法返回value;如果是array、collection、map类型的数据,再调用toSplice方法生成key=value的拼接串。

7、签名校验

7.1、header中参数

header中需要传递参数

7.2、签名实体SignatureHeaders, 绑定request中header信息

@ConfigurationProperties(prefix = "wmhopenapi.validate", exceptionIfInvalid = false)@Signaturepublic class SignatureHeaders {    public static final String SIGNATURE_HEADERS_PREFIX = "wmhopenapi-validate";    public static final Set<String> HEADER_NAME_SET = Sets.newHashSet();    private static final String HEADER_APPID = SIGNATURE_HEADERS_PREFIX + "-appid";    private static final String HEADER_TIMESTAMP = SIGNATURE_HEADERS_PREFIX + "-timestamp";    private static final String HEADER_NONCE = SIGNATURE_HEADERS_PREFIX + "-nonce";    private static final String HEADER_SIGNATURE = SIGNATURE_HEADERS_PREFIX + "-signature";    static {
       HEADER_NAME_SET.add(HEADER_APPID);
       HEADER_NAME_SET.add(HEADER_TIMESTAMP);
       HEADER_NAME_SET.add(HEADER_NONCE);
       HEADER_NAME_SET.add(HEADER_SIGNATURE);
   }    /**
    * 线下分配的值
    * 客户端和服务端各自保存appId对应的appSecret
    */
   @NotBlank(message = "Header中缺少" + HEADER_APPID)    @SignatureField
   private String appid;    /**
    * 线下分配的值
    * 客户端和服务端各自保存,与appId对应
    */
   @SignatureField
   private String appsecret;    /**
    * 时间戳,单位: ms
    */
   @NotBlank(message = "Header中缺少" + HEADER_TIMESTAMP)    @SignatureField
   private String timestamp;    /**
    * 流水号【防止重复提交】; (备注:针对查询接口,流水号只用于日志落地,便于后期日志核查; 针对办理类接口需校验流水号在有效期内的唯一性,以避免重复请求)
    */
   @NotBlank(message = "Header中缺少" + HEADER_NONCE)    @SignatureField
   private String nonce;    /**
    * 签名
    */
   @NotBlank(message = "Header中缺少" + HEADER_SIGNATURE)    private String signature;
}

7.3、根据request中header值生成签名实体SignatureHeaders

private SignatureHeaders generateSignatureHeaders(Signature signature, HttpServletRequest request) throws Exception {    //处理header name
   Map<String, Object> headerMap = Collections.list(request.getHeaderNames())
           .stream()
           .filter(headerName -> SignatureHeaders.HEADER_NAME_SET.contains(headerName))
           .collect(Collectors.toMap(headerName -> headerName.replaceAll("-", "."), headerName -> request.getHeader(headerName)));    //将header信息:name=value转换成PropertySource
   PropertySource propertySource = new MapPropertySource("signatureHeaders", headerMap);    //将header信息绑定到SignatureHeaders对象
   SignatureHeaders signatureHeaders = RelaxedConfigurationBinder.with(SignatureHeaders.class)
           .setPropertySources(propertySource)
           .doBind();
   Optional<String> result = ValidatorUtils.validateResultProcess(signatureHeaders);    if (result.isPresent()) {        throw new ServiceException("WMH5000", result.get());
   }    //从配置中拿到appid对应的appsecret
   String appSecret = limitConstants.getSignatureLimit().get(signatureHeaders.getAppid());    if (StringUtils.isBlank(appSecret)) {
       LOGGER.error("未找到appId对应的appSecret, appId=" + signatureHeaders.getAppid());        throw new ServiceException("WMH5002");
   }    //其他合法性校验
   Long now = System.currentTimeMillis();
   Long requestTimestamp = Long.parseLong(signatureHeaders.getTimestamp());    if ((now - requestTimestamp) > EXPIRE_TIME) {
       String errMsg = "请求时间超过规定范围时间10分钟, signature=" + signatureHeaders.getSignature();
       LOGGER.error(errMsg);        throw new ServiceException("WMH5000", errMsg);
   }
   String nonce = signatureHeaders.getNonce();    if (nonce.length() < 10) {
       String errMsg = "随机串nonce长度最少为10位, nonce=" + nonce;
       LOGGER.error(errMsg);        throw new ServiceException("WMH5000", errMsg);
   }    if (!signature.resubmit()) {
       String existNonce = redisCacheService.getString(nonce);        if (StringUtils.isBlank(existNonce)) {
           redisCacheService.setex(nonce, nonce, (int) TimeUnit.MILLISECONDS.toSeconds(RESUBMIT_DURATION));
       } else {
           String errMsg = "不允许重复请求, nonce=" + nonce;
           LOGGER.error(errMsg);            throw new ServiceException("WMH5000", errMsg);
       }
   }
   //设置appsecret
   signatureHeaders.setAppsecret(appSecret);    return signatureHeaders;
}

生成签名前需要如下几个校验步骤。

  • 处理header name,通过工具类将header信息绑定到签名实体SignatureHeaders对象上。

  • 验证appid是否合法。

  • 根据appid从配置中心中拿到appsecret。

  • 请求是否已经超时,默认10分钟。

  • 随机串是否合法。

  • 是否允许重复请求。

7.4、生成header信息参数拼接

String headersToSplice = SignatureUtils.toSplice(signatureHeaders);

7.5、切面拦截控制层方法,生成method中参数的拼接

private List<String> generateAllSplice(Method method, Object[] args, String headersToSplice) {
   List<String> pathVariables = Lists.newArrayList(), requestParams = Lists.newArrayList();
   String beanParams = StringUtils.EMPTY;    for (int i = 0; i < method.getParameterCount(); ++i) {
       MethodParameter mp = new MethodParameter(method, i);        boolean findSignature = false;        for (Annotation anno : mp.getParameterAnnotations()) {            if (anno instanceof PathVariable) {                if (!Objects.isNull(args[i])) {
                   pathVariables.add(args[i].toString());
               }
               findSignature = true;
           } else if (anno instanceof RequestParam) {
               RequestParam rp = (RequestParam) anno;
               String name = mp.getParameterName();                if (StringUtils.isNotBlank(rp.name())) {
                   name = rp.name();
               }                if (!Objects.isNull(args[i])) {
                   List<String> values = Lists.newArrayList();                    if (args[i].getClass().isArray()) {                        //数组
                       for (int j = 0; j < Array.getLength(args[i]); ++j) {
                           values.add(Array.get(args[i], j).toString());
                       }
                   } else if (ClassUtils.isAssignable(Collection.class, args[i].getClass())) {                        //集合
                       for (Object o : (Collection<?>) args[i]) {
                           values.add(o.toString());
                       }
                   } else {                        //单个值
                       values.add(args[i].toString());
                   }
                   values.sort(Comparator.naturalOrder());
                   requestParams.add(name + "=" + StringUtils.join(values));
               }
               findSignature = true;
           } else if (anno instanceof RequestBody || anno instanceof ModelAttribute) {
               beanParams = SignatureUtils.toSplice(args[i]);
               findSignature = true;
           }            if (findSignature) {                break;
           }
       }        if (!findSignature) {
           LOGGER.info(String.format("签名未识别的注解, method=%s, parameter=%s, annotations=%s", method.getName(), mp.getParameterName(), StringUtils.join(mp.getMethodAnnotations())));
       }
   }
   List<String> toSplices = Lists.newArrayList();
   toSplices.add(headersToSplice);
   toSplices.addAll(pathVariables);
   requestParams.sort(Comparator.naturalOrder());
   toSplices.addAll(requestParams);
   toSplices.add(beanParams);    return toSplices;
}

generateAllSplice方法是在控制层切面内执行,可以在方法执行之前获取到已经绑定好的入参。分别对注有@PathVariable、@RequestParam、@RequestBody、@ModelAttribute注解的参数进行参数拼接的处理。其中注@RequestParam注解的参数需要特殊处理一下,分别考虑数组、集合、原始类型这三种情况。

7.6、对最终的拼接结果重新生成签名信息

SignatureUtils.signature(allSplice.toArray(new String[]{}), signatureHeaders.getAppsecret());

8、客户端使用示例

8.1、生成签名

//初始化请求头信息SignatureHeaders signatureHeaders = new SignatureHeaders();
signatureHeaders.setAppid("111");
signatureHeaders.setAppsecret("222");
signatureHeaders.setNonce(SignatureUtils.generateNonce());
signatureHeaders.setTimestamp(String.valueOf(System.currentTimeMillis()));
List<String> pathParams = new ArrayList<>();//初始化path中的数据pathParams.add(SignatureUtils.encode("18237172801", signatureHeaders.getAppsecret()));//调用签名工具生成签名signatureHeaders.setSignature(SignatureUtils.signature(signatureHeaders, pathParams, null, null));
System.out.println("签名数据: " + signatureHeaders);
System.out.println("请求数据: " + pathParams);

8.2、输出结果

拼接结果: appid=111^_^appsecret=222^_^nonce=c9e778ba668c8f6fedf35634eb493af6304d54392d990262d9e7c1960b475b67^_^timestamp=1538207443910^_^w8rAwcXDxcDKwsM=^_^签名数据: SignatureHeaders{appid=111, appsecret=222, timestamp=1538207443910, nonce=c9e778ba668c8f6fedf35634eb493af6304d54392d990262d9e7c1960b475b67, signature=0a7d0b5e802eb5e52ac0cfcd6311b0faba6e2503a9a8d1e2364b38617877574d}请求数据: [w8rAwcXDxcDKwsM=]

9、思考

上述的签名方案的实现校验逻辑是在控制层的切面内完成的。如果项目用的是springmvc框架,可以放在Filter或者拦截器里吗?很明显是不行的(因为ServletRequest的输入流InputStream 在默认情况只能读取一次)。上述方案需要获取绑定后的参数结果,然后执行签名校验逻辑。在执行控制层方法之前,springmvc已经帮我们完成了绑定的步骤,当然了,在绑定的过程中会解析ServletRequest中参数信息(例如path参数、parameter参数、body参数)。

其实如果我们能在Filter或者拦截器中实现上述方案,那么复杂度将会大大的降低。首先考虑如何让ServletRequest的输入流InputStream可以多次读取,然后通过ServletRequest获取path variable(对应@PathVariable)、parameters(对应@RequestParam)、body(对应@RequestBody)参数,最后整体按照规则进行拼接并生成签名。


本文作者:网友 来源:博客园
CIO之家 www.ciozj.com 微信公众号:imciow
    >>频道首页  >>网站首页   纠错  >>投诉
版权声明:CIO之家尊重行业规范,每篇文章都注明有明确的作者和来源;CIO之家的原创文章,请转载时务必注明文章作者和来源;
延伸阅读
也许感兴趣的
我们推荐的
主题最新
看看其它的