接口签名工具

前言

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

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

参数名称 参数类型 是否必须 参数描述
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();
    }
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容