美文网首页
接口签名工具

接口签名工具

作者: 缓慢移动的蜗牛 | 来源:发表于2022-04-07 17:50 被阅读0次

    前言

    假设我们的系统对外提供了一些公共接口,但是这些接口只针对开通了服务的用户开发,那么如何保证我们提供的接口不被未授权的用户调用,用户传递的参数未被篡改?其中一种方法就是使用接口签名的方式对外提供服务。

    如果想更简单一点的话,可以只校验我们向用户提交的密匙。例如:用户的每个请求都必须包含指定的请求头

    参数名称 参数类型 是否必须 参数描述
    token String 验证加密值 Md5(key+Timespan+SecretKey) 加密的32位大写字符串)
    Timespan String 精确到秒的Unix时间戳(String.valueOf(System.currentTimeMillis() / 1000))

    这样,只需要简单简要一下token即可

    接口签名

    Headerd的公共参数

    参数名称 参数类型 是否必须 参数描述
    x-appid String 分配给应用的appid。
    x-sign String API输入参数签名结果,签名算法参照下面的介绍。
    x-timestamp String 时间戳,格式为yyyy-MM-dd HH:mm:ss,时区为GMT+8,例如:2020-01-01 12:00:00。API服务端允许客户端请求最大时间误差为10分钟。
    sign-method String 签名的摘要算法,可选值为:hmac,md5,hmac-sha256(默认)。

    签名算法

    为了防止API调用过程中被黑客恶意篡改,调用任何一个API都需要携带签名,服务端会根据请求参数,对签名进行验证,签名不合法的请求将会被拒绝。目前支持的签名算法有三种:MD5(sign-method=md5),HMAC_MD5(sign-method=hmac),HMAC_SHA256(sign-method=hmac-sha256),签名大体过程如下:

    • 对API请求参数,根据参数名称的ASCII码表的顺序排序(空值不计入在内)。

      Path Variable:按照path中的字典顺序将所有value进行拼接, 记做X 例如:aaabbb
      Parameter:按照key=values(多个value按照字典顺序拼接)字典顺序进行拼接,记做Y 例如:kvkvkvkv
      Body:按照key=value字典顺序进行拼接,记做Z 例如:namezhangsanage10

    • 将排序好的参数名和参数值拼装在一起(规则:appsecret+X+Y+X+timestamp+appsecret)

    • 把拼装好的字符串采用utf-8编码,使用签名算法对编码后的字节流进行摘要。

    • 将摘要得到的字节流结果使用十六进制表示,如:hex("helloworld".getBytes("utf-8")) = "68656C6C6F776F726C64"

    说明:MD5和HMAC_MD5都是128位长度的摘要算法,用16进制表示,一个十六进制的字符能表示4个位,所以签名后的字符串长度固定为32个十六进制字符。

    密匙管理

    类似于这样的一个密匙管理模块,具体的就省略了,本示例中使用使用配置替代


    密匙管理.png

    使用AOP来校验签名

    yml的配置

    apps:
      open: true  # 是否开启签名校验
      appPair:
        abc: aaaaaaaaaaaaaaaaaaa
    

    aop的代码

    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.google.common.base.Charsets;
    import com.google.common.collect.ImmutableList;
    import com.nanc.common.entity.R;
    import com.nanc.common.utils.SignUtil;
    import lombok.extern.slf4j.Slf4j;
    import org.apache.commons.collections4.MapUtils;
    import org.apache.commons.lang3.StringUtils;
    import org.apache.commons.lang3.time.DateUtils;
    import org.aspectj.lang.ProceedingJoinPoint;
    import org.aspectj.lang.annotation.Around;
    import org.aspectj.lang.annotation.Aspect;
    import org.aspectj.lang.annotation.Pointcut;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
    import org.springframework.boot.context.properties.ConfigurationProperties;
    import org.springframework.stereotype.Component;
    import org.springframework.web.context.request.RequestAttributes;
    import org.springframework.web.context.request.RequestContextHolder;
    import org.springframework.web.context.request.ServletRequestAttributes;
    import org.springframework.web.servlet.HandlerMapping;
    import com.nanc.demo.config.filter.ContentCachingRequestWrapper;
    
    import javax.servlet.http.HttpServletRequest;
    import java.text.ParseException;
    import java.util.Date;
    import java.util.List;
    import java.util.Map;
    import java.util.Objects;
    
    
    @Aspect
    @Component
    @ConfigurationProperties(prefix = "apps")
    @Slf4j
    public class SignatureAspect {
        @Autowired
        private ObjectMapper objectMapper;
    
        /**
         * 是否开启签名校验
         */
        private boolean open;
        /**
         * appid与appsecret对
         */
        private Map<String, String> appPair;
    
        private static final List<String> SIGN_METHOD_LISt = ImmutableList.<String>builder()
                .add("MD5")
                .add("md5")
                .add("HMAC")
                .add("hmac")
                .add("HMAC-SHA256")
                .add("hmac-sha256")
                .build();
    
    
    
        @Pointcut("execution(public * com.nanc.demo.modules.test.controller.MyTestController.testSignature(..))")
        public void pointCut(){};
    
        @Around("pointCut()")
        public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable{
            try{
                if (open) {
                    checkSign(joinPoint);
                }
                // 执行目标 service
                Object result = joinPoint.proceed();
                return result;
            }catch (Throwable e){
                log.error("", e);
                return R.error(e.getMessage());
            }
    
        }
    
        /**
         *
         * @throws Exception
         */
        private void checkSign(ProceedingJoinPoint joinPoint) throws Exception{
            RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
            ServletRequestAttributes sra = (ServletRequestAttributes)requestAttributes;
            HttpServletRequest request = Objects.requireNonNull(sra).getRequest();
            ContentCachingRequestWrapper requestWrapper = (ContentCachingRequestWrapper) request;
    
    
            String oldSign = request.getHeader("x-sign");
            if (StringUtils.isBlank(oldSign)) {
                throw new RuntimeException("未获取到签名x-sign的信息");
            }
    
    
            String appid = request.getHeader("x-appid");
            if (StringUtils.isBlank(appid) || !appPair.containsKey(appid)) {
                throw new RuntimeException("x-appid有误");
            }
    
            String signMethod = request.getHeader("sign-method");
            if (StringUtils.isNotBlank(signMethod) && !SIGN_METHOD_LISt.contains(signMethod)) {
                throw new RuntimeException("签名算法有误");
            }
    
    
            //时间戳,格式为yyyy-MM-dd HH:mm:ss,时区为GMT+8,例如:2016-01-01 12:00:00。API服务端允许客户端请求最大时间误差为10分钟。
            String timeStamp = request.getHeader("x-timestamp");
            if (StringUtils.isBlank(timeStamp)) {
                throw new RuntimeException("时间戳x-timestamp不能为空");
            }
    
            try {
                Date tm = DateUtils.parseDate(timeStamp, "yyyy-MM-dd HH:mm:ss");
                //   tm>=new Date()-10m, tm< new Date()
                if (tm.before(DateUtils.addMinutes(new Date(), -10)) || tm.after(new Date())) {
                    throw new RuntimeException("签名时间过期或超期");
                }
            } catch (ParseException exception) {
                throw new RuntimeException("时间戳x-timestamp格式有误");
            }
    
            //获取path variable(对应@PathVariable)
            String[] paths = new String[0];
            Map<String, String> uriTemplateVars = (Map<String, String>)sra.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);
            if (MapUtils.isNotEmpty(uriTemplateVars)) {
                paths = uriTemplateVars.values().toArray(new String[]{});
            }
    
            //获取parameters(对应@RequestParam)
            Map<String, String[]> parameterMap = request.getParameterMap();
    
    
            // 获取body(对应@RequestBody)
            String body = new String(IOUtils.toByteArray(requestWrapper.getInputStream()), Charsets.UTF_8);
    
            String newSign = null;
            try {
                newSign = SignUtil.sign(MapUtils.getString(appPair, appid, ""), signMethod, timeStamp, paths, parameterMap, body);
                if (!StringUtils.equals(oldSign, newSign)) {
                    throw new RuntimeException("签名不一致");
                }
            } catch (Exception e) {
                throw new RuntimeException("校验签名出错");
            }
    
            log.info("----aop----paths---{}", objectMapper.writeValueAsString(paths));
            log.info("----aop----parameters---{}", objectMapper.writeValueAsString(parameterMap));
            log.info("----aop----body---{}", body);
            log.info("----aop---生成签名---{}", newSign);
        }
    
        public Map<String, String> getAppPair() {
            return appPair;
        }
    
        public void setAppPair(Map<String, String> appPair) {
            this.appPair = appPair;
        }
    
        public boolean isOpen() {
            return open;
        }
    
        public void setOpen(boolean open) {
            this.open = open;
        }
    }
    

    但是这里还有一些问题需要解决,在AOP中,如果获取了request的body内容,那么在控制层,再使用@RequestBody注解的话,就会获取不到body的内容了,因为request的inputstream只能被读取一次。解决此问题的一个简单方式是使用reqeust的包装对象

    import java.io.IOException;
    import javax.servlet.FilterChain;
    import javax.servlet.ServletException;
    import javax.servlet.ServletRequest;
    import javax.servlet.ServletResponse;
    import javax.servlet.http.HttpServletRequest;
    import org.springframework.stereotype.Component;
    import org.springframework.web.filter.GenericFilterBean;
    
    /**
     * 使用ContentCachingRequestWrapper类,它是原始HttpServletRequest对象的包装。 当我们读取请求正文时,ContentCachingRequestWrapper会缓存内容供以后使用。
     *
     * @date 2020/8/22 10:40
     */
    @Component
    public class CachingRequestBodyFilter extends GenericFilterBean {
    
        @Override
        public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
                throws IOException, ServletException {
           ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper((HttpServletRequest) servletRequest);
    
            chain.doFilter(wrappedRequest, servletResponse);
        }
    }
    

    reqeust的包装类

    import javax.servlet.ReadListener;
    import javax.servlet.ServletInputStream;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletRequestWrapper;
    import java.io.BufferedReader;
    import java.io.ByteArrayInputStream;
    import java.io.IOException;
    import java.io.InputStreamReader;
    import java.nio.charset.StandardCharsets;
    
    /**
     * 解决不能重复读取使用request请求中的数据流 问题
     * @date 2022/4/6 21:50
     */
    public class ContentCachingRequestWrapper extends HttpServletRequestWrapper {
    
        private final byte[] body;
    
        public ContentCachingRequestWrapper(HttpServletRequest request) {
            super(request);
            StringBuilder sb = new StringBuilder();
    
            String enc = super.getCharacterEncoding();
            enc = (enc != null ? enc : StandardCharsets.UTF_8.name());
    
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(request.getInputStream(), enc))){
                String line = "";
                while ((line = reader.readLine()) != null) {
                    sb.append(line);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
            body = sb.toString().getBytes(StandardCharsets.UTF_8);
        }
    
        @Override
        public ServletInputStream getInputStream() throws IOException {
            final ByteArrayInputStream inputStream = new ByteArrayInputStream(body);
    
            return new ServletInputStream() {
    
                @Override
                public boolean isFinished() {
                    return false;
                }
    
                @Override
                public boolean isReady() {
                    return false;
                }
    
                @Override
                public void setReadListener(ReadListener readListener) {
    
                }
    
                @Override
                public int read() throws IOException {
                    return inputStream.read();
                }
            };
        }
    
        public byte[] getBody() {
            return body;
        }
    }
    

    工具类

    使用了hutool工具包

    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.4.0</version>
    </dependency>
    

    具体的工具类

    import cn.hutool.crypto.digest.DigestAlgorithm;
    import cn.hutool.crypto.digest.Digester;
    import cn.hutool.crypto.digest.HMac;
    import cn.hutool.crypto.digest.HmacAlgorithm;
    import com.alibaba.fastjson.JSON;
    import org.apache.commons.collections4.MapUtils;
    import org.apache.commons.lang3.ArrayUtils;
    import org.apache.commons.lang3.StringUtils;
    
    import java.util.Arrays;
    import java.util.Map;
    import java.util.Objects;
    
    /**
     * 生成接口签名的工具类
     */
    public class SignUtil {
    
        /**
         *
         * 例如: hmac_sha256(appsecret+X+Y+X+timestamp+appsecret)
         * @param appsecret
         * @param signMethod 默认为:HMAC_SHA256
         * @param paths 对应@PathVariable
         * @param params 对应@RequestParam
         * @param body 对应@RequestBody
         * @return
         */
        public static String sign(String appsecret, String signMethod, String timestamp, String[] paths,
                Map<String, String[]> params, String body) {
            StringBuilder sb = new StringBuilder(appsecret);
    
            // path variable(对应@PathVariable)
            if (ArrayUtils.isNotEmpty(paths)) {
                String pathValues = String.join("", Arrays.stream(paths).sorted().toArray(String[]::new));
                sb.append(pathValues);
            }
    
            // parameters(对应@RequestParam)
            if (MapUtils.isNotEmpty(params)) {
                params.entrySet().stream().filter(entry -> Objects.nonNull(entry.getValue())) // 为空的不计入
                        .sorted(Map.Entry.comparingByKey()).forEach(paramEntry -> {
                            String paramValue = String.join("",
                                    Arrays.stream(paramEntry.getValue()).sorted().toArray(String[]::new));
                            sb.append(paramEntry.getKey()).append(paramValue);
                        });
            }
    
            // body(对应@RequestBody)
            if (StringUtils.isNotBlank(body)) {
                Map<String, Object> map = JSON.parseObject(body, Map.class);
                map.entrySet().stream().filter(entry -> Objects.nonNull(entry.getValue())) // 为空的不计入
                        .sorted(Map.Entry.comparingByKey()).forEach(paramEntry -> {
                            sb.append(paramEntry.getKey()).append(paramEntry.getValue());
                        });
            }
            sb.append(timestamp).append(appsecret);
    
            String sign = new String();
            if (StringUtils.isBlank(signMethod) || StringUtils.equalsIgnoreCase(signMethod, "HMAC-SHA256")) {
                sign = new HMac(HmacAlgorithm.HmacSHA256, appsecret.getBytes()).digestHex(sb.toString());
            }
            else if (StringUtils.equalsIgnoreCase(signMethod, "HMAC")) {
                sign = new HMac(HmacAlgorithm.HmacMD5, appsecret.getBytes()).digestHex(sb.toString());
            }
            else {
                Digester md5 = new Digester(DigestAlgorithm.MD5);
                sign = md5.digestHex(sb.toString());
            }
            return sign.toUpperCase();
        }
    }
    

    相关文章

      网友评论

          本文标题:接口签名工具

          本文链接:https://www.haomeiwen.com/subject/rzfdsrtx.html