接口签名工具

前言

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

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

参数名称 参数类型 是否必须 参数描述
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 appPair;

    private static final List SIGN_METHOD_LISt = ImmutableList.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 uriTemplateVars = (Map)sra.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);
        if (MapUtils.isNotEmpty(uriTemplateVars)) {
            paths = uriTemplateVars.values().toArray(new String[]{});
        }

        //获取parameters(对应@RequestParam)
        Map 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 getAppPair() {
        return appPair;
    }

    public void setAppPair(Map 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工具包


    cn.hutool
    hutool-all
    5.4.0

具体的工具类

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 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 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();
    }
}

你可能感兴趣的:(接口签名工具)