接口簽名工具

前言

假設(shè)我們的系統(tǒng)對外提供了一些公共接口婆瓜,但是這些接口只針對開通了服務(wù)的用戶開發(fā)快集,那么如何保證我們提供的接口不被未授權(quán)的用戶調(diào)用贡羔,用戶傳遞的參數(shù)未被篡改?其中一種方法就是使用接口簽名的方式對外提供服務(wù)个初。

如果想更簡單一點的話乖寒,可以只校驗我們向用戶提交的密匙。例如:用戶的每個請求都必須包含指定的請求頭

參數(shù)名稱 參數(shù)類型 是否必須 參數(shù)描述
token String 驗證加密值 Md5(key+Timespan+SecretKey) 加密的32位大寫字符串)
Timespan String 精確到秒的Unix時間戳(String.valueOf(System.currentTimeMillis() / 1000))

這樣院溺,只需要簡單簡要一下token即可

接口簽名

Headerd的公共參數(shù)

參數(shù)名稱 參數(shù)類型 是否必須 參數(shù)描述
x-appid String 分配給應(yīng)用的appid楣嘁。
x-sign String API輸入?yún)?shù)簽名結(jié)果,簽名算法參照下面的介紹珍逸。
x-timestamp String 時間戳逐虚,格式為yyyy-MM-dd HH:mm:ss,時區(qū)為GMT+8谆膳,例如:2020-01-01 12:00:00叭爱。API服務(wù)端允許客戶端請求最大時間誤差為10分鐘。
sign-method String 簽名的摘要算法漱病,可選值為:hmac买雾,md5,hmac-sha256(默認)杨帽。

簽名算法

為了防止API調(diào)用過程中被黑客惡意篡改漓穿,調(diào)用任何一個API都需要攜帶簽名,服務(wù)端會根據(jù)請求參數(shù)注盈,對簽名進行驗證晃危,簽名不合法的請求將會被拒絕。目前支持的簽名算法有三種:MD5(sign-method=md5)老客,HMAC_MD5(sign-method=hmac)僚饭,HMAC_SHA256(sign-method=hmac-sha256),簽名大體過程如下:

  • 對API請求參數(shù)沿量,根據(jù)參數(shù)名稱的ASCII碼表的順序排序(空值不計入在內(nèi))浪慌。

    Path Variable:按照path中的字典順序?qū)⑺衯alue進行拼接, 記做X 例如:aaabbb
    Parameter:按照key=values(多個value按照字典順序拼接)字典順序進行拼接朴则,記做Y 例如:kvkvkvkv
    Body:按照key=value字典順序進行拼接权纤,記做Z 例如:namezhangsanage10

  • 將排序好的參數(shù)名和參數(shù)值拼裝在一起(規(guī)則:appsecret+X+Y+X+timestamp+appsecret)

  • 把拼裝好的字符串采用utf-8編碼,使用簽名算法對編碼后的字節(jié)流進行摘要乌妒。

  • 將摘要得到的字節(jié)流結(jié)果使用十六進制表示汹想,如: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);
            }
            // 執(zhí)行目標(biāo) 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,時區(qū)為GMT+8庞萍,例如:2016-01-01 12:00:00拧烦。API服務(wù)端允許客戶端請求最大時間誤差為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(對應(yīng)@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(對應(yīng)@RequestParam)
        Map<String, String[]> parameterMap = request.getParameterMap();


        // 獲取body(對應(yīng)@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內(nèi)容,那么在控制層私恬,再使用@RequestBody注解的話债沮,就會獲取不到body的內(nèi)容了,因為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對象的包裝。 當(dāng)我們讀取請求正文時荣德,ContentCachingRequestWrapper會緩存內(nèi)容供以后使用隧土。
 *
 * @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;

/**
 * 解決不能重復(fù)讀取使用request請求中的數(shù)據(jù)流 問題
 * @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 對應(yīng)@PathVariable
     * @param params 對應(yīng)@RequestParam
     * @param body 對應(yīng)@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(對應(yīng)@PathVariable)
        if (ArrayUtils.isNotEmpty(paths)) {
            String pathValues = String.join("", Arrays.stream(paths).sorted().toArray(String[]::new));
            sb.append(pathValues);
        }

        // parameters(對應(yīng)@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(對應(yīng)@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();
    }
}
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市命爬,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌辐脖,老刑警劉巖饲宛,帶你破解...
    沈念sama閱讀 219,188評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異嗜价,居然都是意外死亡艇抠,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,464評論 3 395
  • 文/潘曉璐 我一進店門久锥,熙熙樓的掌柜王于貴愁眉苦臉地迎上來家淤,“玉大人,你說我怎么就攤上這事瑟由⌒踔兀” “怎么了?”我有些...
    開封第一講書人閱讀 165,562評論 0 356
  • 文/不壞的土叔 我叫張陵歹苦,是天一觀的道長青伤。 經(jīng)常有香客問我,道長殴瘦,這世上最難降的妖魔是什么狠角? 我笑而不...
    開封第一講書人閱讀 58,893評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮蚪腋,結(jié)果婚禮上丰歌,老公的妹妹穿的比我還像新娘姨蟋。我一直安慰自己,他們只是感情好立帖,可當(dāng)我...
    茶點故事閱讀 67,917評論 6 392
  • 文/花漫 我一把揭開白布眼溶。 她就那樣靜靜地躺著,像睡著了一般厘惦。 火紅的嫁衣襯著肌膚如雪偷仿。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,708評論 1 305
  • 那天宵蕉,我揣著相機與錄音酝静,去河邊找鬼。 笑死羡玛,一個胖子當(dāng)著我的面吹牛别智,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播稼稿,決...
    沈念sama閱讀 40,430評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼薄榛,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了让歼?” 一聲冷哼從身側(cè)響起敞恋,我...
    開封第一講書人閱讀 39,342評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎谋右,沒想到半個月后硬猫,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,801評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡改执,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,976評論 3 337
  • 正文 我和宋清朗相戀三年啸蜜,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片辈挂。...
    茶點故事閱讀 40,115評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡衬横,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出终蒂,到底是詐尸還是另有隱情蜂林,我是刑警寧澤,帶...
    沈念sama閱讀 35,804評論 5 346
  • 正文 年R本政府宣布拇泣,位于F島的核電站悉尾,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏挫酿。R本人自食惡果不足惜构眯,卻給世界環(huán)境...
    茶點故事閱讀 41,458評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望早龟。 院中可真熱鬧惫霸,春花似錦猫缭、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,008評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至硅卢,卻和暖如春射窒,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背将塑。 一陣腳步聲響...
    開封第一講書人閱讀 33,135評論 1 272
  • 我被黑心中介騙來泰國打工脉顿, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人点寥。 一個月前我還...
    沈念sama閱讀 48,365評論 3 373
  • 正文 我出身青樓艾疟,卻偏偏與公主長得像,于是被迫代替她去往敵國和親敢辩。 傳聞我的和親對象是個殘疾皇子蔽莱,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,055評論 2 355

推薦閱讀更多精彩內(nèi)容