安全優雅的RESTful API簽名實現方案

安全優雅的RESTful API簽名實現方案

一、接口簽名的必要性

在爲第三方系統提供接口的時候,確定要考慮接口數據的安全問題,好比數據是否被篡改,數據是否已通過時,數據是否能夠重複提交等問題。其中我認爲最終要的仍是數據是否被篡改。在此分享一下個人關於接口簽名的實踐方案。html

二、項目中籤名方案痛點

  • 每一個接口有各自的簽名方案,不統一,維護成本較高。
  • 沒有對消息實體進行簽名,沒法避免數據被篡改。
  • 沒法避免數據重複提交。

三、簽名及驗證流程

簽名流程說明

四、簽名規則

  • 線下分配appid和appsecret,針對不一樣的調用方分配不一樣的appid和appsecret。
  • 加入timestamp(時間戳),10分鐘內數據有效。
  • 加入流水號nonce(防止重複提交),至少爲10位。針對查詢接口,流水號只用於日誌落地,便於後期日誌覈查。 針對辦理類接口需校驗流水號在有效期內的惟一性,以免重複請求。
  • 加入signature,全部數據的簽名信息。
    其中appid、timestamp、nonce、signature這四個字段放入請求頭中。

五、簽名生成

5.一、數據部分

  • 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。java

5.二、請求頭部分

X="appid=xxxnonce=xxxtimestamp=xxx"redis

5.三、生成簽名

最終拼接值=XY
最後將最終拼接值按照以下方法進行加密獲得簽名(signature)。算法

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

六、簽名算法實現

6.一、指定哪些接口或者哪些實體須要簽名

@Target({TYPE, METHOD})
@Retention(RUNTIME)
@Documented
public @interface Signature {
    String ORDER_SORT = "ORDER_SORT";//按照order值排序
    String ALPHA_SORT = "ALPHA_SORT";//字典序排序
    boolean resubmit() default true;//容許重複請求
    String sort() default Signature.ALPHA_SORT;
}

6.二、指定哪些字段須要簽名

@Target({FIELD})
@Retention(RUNTIME)
@Documented
public @interface SignatureField {
    //簽名順序
    int order() default 0;
    //字段name自定義值
    String customName() default "";
    //字段value自定義值
    String customValue() default "";
}

6.三、簽名核心算法(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.一、header中參數

header中須要傳遞參數

7.二、簽名實體SignatureHeaders, 綁定request中header信息

@ConfigurationProperties(prefix = "wmhopenapi.validate", exceptionIfInvalid = false)
@Signature
public 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.三、根據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;
}

生成簽名前須要以下幾個校驗步驟。spring

  • 處理header name,經過工具類將header信息綁定到簽名實體SignatureHeaders對象上。
  • 驗證appid是否合法。
  • 根據appid從配置中心中拿到appsecret。
  • 請求是否已經超時,默認10分鐘。
  • 隨機串是否合法。
  • 是否容許重複請求。

7.四、生成header信息參數拼接

String headersToSplice = SignatureUtils.toSplice(signatureHeaders);

7.五、切面攔截控制層方法,生成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註解的參數須要特殊處理一下,分別考慮數組、集合、原始類型這三種狀況。apache

7.六、對最終的拼接結果從新生成簽名信息

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

八、客戶端使用示例

8.一、生成簽名

//初始化請求頭信息
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.二、輸出結果

拼接結果: appid=111^_^appsecret=222^_^nonce=c9e778ba668c8f6fedf35634eb493af6304d54392d990262d9e7c1960b475b67^_^timestamp=1538207443910^_^w8rAwcXDxcDKwsM=^_^
簽名數據: SignatureHeaders{appid=111, appsecret=222, timestamp=1538207443910, nonce=c9e778ba668c8f6fedf35634eb493af6304d54392d990262d9e7c1960b475b67, signature=0a7d0b5e802eb5e52ac0cfcd6311b0faba6e2503a9a8d1e2364b38617877574d}
請求數據: [w8rAwcXDxcDKwsM=]

九、思考

上述的簽名方案的實現校驗邏輯是在控制層的切面內完成的。若是項目用的是springmvc框架,能夠放在Filter或者攔截器裏嗎?很明顯是不行的(由於ServletRequest的輸入流InputStream 在默認狀況只能讀取一次)。上述方案須要獲取綁定後的參數結果,而後執行簽名校驗邏輯。在執行控制層方法以前,springmvc已經幫咱們完成了綁定的步驟,固然了,在綁定的過程當中會解析ServletRequest中參數信息(例如path參數、parameter參數、body參數)。api

其實若是咱們能在Filter或者攔截器中實現上述方案,那麼複雜度將會大大的下降。首先考慮如何讓ServletRequest的輸入流InputStream能夠屢次讀取,而後經過ServletRequest獲取path variable(對應@PathVariable)、parameters(對應@RequestParam)、body(對應@RequestBody)參數,最後總體按照規則進行拼接並生成簽名。數組

優化方案參考:https://www.cnblogs.com/hujunzheng/p/10178584.html安全

相關文章
相關標籤/搜索