SpringBoot接口 - API接口有哪些不安全的因素?如何對接口進行簽名?

在以SpringBoot開發後臺API接口時,會存在哪些接口不安全的因素呢?通常如何去解決的呢?本文主要介紹API接口有不安全的因素以及常見的保證接口安全的方式,重點實踐如何對接口進行簽名。@pdai

準備知識點

建議從接口整體的安全體系角度來理解,比如存在哪些不安全的因素,加密解密等知識點。

API接口有哪些不安全的因素?

這裏從體系角度,簡單列舉一些不安全的因素:

  • 開發者訪問開放接口
    • 是不是一個合法的開發者?
  • 多客戶端訪問接口
    • 是不是一個合法的客戶端?
  • 用戶訪問接口
    • 是不是一個合法的用戶?
    • 有沒有權限訪問接口?
  • 接口傳輸
    • http明文傳輸數據?
  • 其它方面
    • 接口重放,上文介紹的接口冪等
    • 接口超時,加timestamp控制?
    • ...

常見的保證接口安全的方式?

針對上述接口存在的不安全因素,這裏向你展示一些典型的保障接口安全的方式。

AccessKey&SecretKey

這種設計一般用在開發接口的安全,以確保是一個合法的開發者

  • AccessKey: 開發者唯一標識
  • SecretKey: 開發者密鑰

以阿里雲相關產品爲例

認證和授權

從兩個視角去看

  • 第一: 認證和授權,認證是訪問者的合法性,授權是訪問者的權限分級;
  • 第二: 其中認證包括對客戶端的認證以及對用戶的認證
  • 對於客戶端的認證

典型的是AppKey&AppSecret,或者ClientId&ClientSecret等

比如oauth2協議的client cridential模式

https://api.xxxx.com/token?grant_type=client_credentials&client_id=CLIENT_ID&client_secret=CLIENT_SECRET

grant_type參數等於client_credentials表示client credentials方式,client_id是客戶端id,client_secret是客戶端密鑰。

返回token後,通過token訪問其它接口。

  • 對於用戶的認證和授權

比如oauth2協議的授權碼模式(authorization code)和密碼模式(resource owner password credentials)

https://api.xxxx.com/token?grant_type=password&username=USERNAME&password=PASSWORD&client_id=CLIENT_ID&scope=read

grant_type參數等於password表示密碼方式,client_id是客戶端id,username是用戶名,password是密碼。

(PS:password模式只有在授權碼模式(authorization code)不可用時纔會採用,這裏只是舉個例子而已)

可選參數scope表示申請的權限範圍。(相關開發框架可以參考spring security, Apache Shiro,SA-Token等)

https

從接口傳輸安全的角度,防止接口數據明文傳輸, 具體可以看這裏

HTTP 有以下安全性問題:

  • 使用明文進行通信,內容可能會被竊聽;
  • 不驗證通信方的身份,通信方的身份有可能遭遇僞裝;
  • 無法證明報文的完整性,報文有可能遭篡改。

HTTPs 並不是新協議,而是讓 HTTP 先和 SSL(Secure Sockets Layer)通信,再由 SSL 和 TCP 通信,也就是說 HTTPs 使用了隧道進行通信。

通過使用 SSL,HTTPs 具有了加密(防竊聽)、認證(防僞裝)和完整性保護(防篡改)。

接口簽名(加密)

接口簽名(加密),主要防止請求參數被篡改。特別是安全要求比較高的接口,比如支付領域的接口。

  • 簽名的主要流程

首先我們需要分配給客戶端一個私鑰用於URL簽名加密,一般的簽名算法如下:

1、首先對請求參數按key進行字母排序放入有序集合中(其它參數請參看後續補充部分);

2、對排序完的數組鍵值對用&進行連接,形成用於加密的參數字符串;

3、在加密的參數字符串前面或者後面加上私鑰,然後用加密算法進行加密,得到sign,然後隨着請求接口一起傳給服務器。

例如:
https://api.xxxx.com/token?key=value&timetamp=xxxx&sign=xxxx-xxx-xxx-xxxx

服務器端接收到請求後,用同樣的算法獲得服務器的sign,對比客戶端的sign是否一致,如果一致請求有效;如果不一致返回指定的錯誤信息。

  • 補充:對什麼簽名?
  1. 主要包括請求參數,這是最主要的部分,簽名的目的要防止參數被篡改,就要對可能被篡改的參數簽名
  2. 同時考慮到請求參數的來源可能是請求路徑path中,請求header中,請求body中。
  3. 如果對客戶端分配了AppKey&AppSecret,也可加入簽名計算;
  4. 考慮到其它冪等,token失效等,也會將涉及的參數一併加入簽名,比如timestamp,流水號nonce等(這些參數可能來源於header)
  • 補充: 簽名算法?

一般涉及這塊,主要包含三點:密鑰,簽名算法,簽名規則

  1. 密鑰secret: 前後端約定的secret,這裏要注意前端可能無法妥善保存好secret,比如SPA單頁應用;
  2. 簽名算法:也不一定要是對稱加密算法,對稱是反過來解析sign,這裏是用同樣的算法和規則計算出sign,並對比前端傳過來的sign是否一致。
  3. 簽名規則:比如多次加鹽加密等;

PS:有讀者會問,我們是可能從有些客戶端獲取密鑰,算法和規則的(比如前端SPA單頁應用生成的js中獲取密鑰,算法和規則),那麼簽名的意義在哪裏?我認爲簽名是手段而不是目的,簽名是加大攻擊者攻擊難度的一種手段,至少是可以抵擋大部分簡單的攻擊的,再加上其它防範方式(流水號,時間戳,token等)進一步提升攻擊的難度而已。

  • 補充:簽名和加密是不是一回事?

嚴格來說不是一回事:

  1. 簽名是通過對參數按照指定的算法、規則計算出sign,最後前後端通過同樣的算法計算出sign是否一致來防止參數篡改的,所以你可以看到參數是明文的,只是多加了一個計算出的sign。

  2. 加密是對請求的參數加密,後端進行解密;同時有些情況下,也會對返回的response進行加密,前端進行解密;這裏存在加密和解密的過程,所以思路上必然是對稱加密的形式+時間戳接口時效性等。

  • 補充:簽名放在哪裏?

簽名可以放在請求參數中(path中,body中等),更爲優雅的可以放在HEADER中,比如X-Sign(通常第三方的header參數以X-開頭)

  • 補充:大廠開放平臺是怎麼做的呢?哪些可以借鑑?

以騰訊開放平臺爲例,請參考騰訊開放平臺第三方應用簽名參數sig的說明

實現案例

本例子採用AOP攔截自定義註解方式實現,主要看實現的思路而已(簽名的目的要防止參數被篡改,就要對可能被篡改的參數簽名)。@pdai

定義註解

package tech.pdai.springboot.api.sign.config.sign;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @author pdai
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Signature {
}

AOP攔截

這裏可以看到需要對所有用戶可能修改的參數點進行按規則簽名

package tech.pdai.springboot.api.sign.config.sign;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Objects;

import javax.servlet.http.HttpServletRequest;

import cn.hutool.core.text.CharSequenceUtil;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
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.context.request.ServletWebRequest;
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.web.util.ContentCachingRequestWrapper;
import tech.pdai.springboot.api.sign.config.exception.BusinessException;
import tech.pdai.springboot.api.sign.util.SignUtil;

/**
 * @author pdai
 */
@Aspect
@Component
public class SignAspect {

    /**
     * SIGN_HEADER.
     */
    private static final String SIGN_HEADER = "X-SIGN";

    /**
     * pointcut.
     */
    @Pointcut("execution(@tech.pdai.springboot.api.sign.config.sign.Signature * *(..))")
    private void verifySignPointCut() {
        // nothing
    }

    /**
     * verify sign.
     */
    @Before("verifySignPointCut()")
    public void verify() {
        HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
        String sign = request.getHeader(SIGN_HEADER);

        // must have sign in header
        if (CharSequenceUtil.isBlank(sign)) {
            throw new BusinessException("no signature in header: " + SIGN_HEADER);
        }

        // check signature
        try {
            String generatedSign = generatedSignature(request);
            if (!sign.equals(generatedSign)) {
                throw new BusinessException("invalid signature");
            }
        } catch (Throwable throwable) {
            throw new BusinessException("invalid signature");
        }
    }

    private String generatedSignature(HttpServletRequest request) throws IOException {
        // @RequestBody
        String bodyParam = null;
        if (request instanceof ContentCachingRequestWrapper) {
            bodyParam = new String(((ContentCachingRequestWrapper) request).getContentAsByteArray(), StandardCharsets.UTF_8);
        }

        // @RequestParam
        Map<String, String[]> requestParameterMap = request.getParameterMap();

        // @PathVariable
        String[] paths = null;
        ServletWebRequest webRequest = new ServletWebRequest(request, null);
        Map<String, String> uriTemplateVars = (Map<String, String>) webRequest.getAttribute(
                HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);
        if (!CollectionUtils.isEmpty(uriTemplateVars)) {
            paths = uriTemplateVars.values().toArray(new String[0]);
        }

        return SignUtil.sign(bodyParam, requestParameterMap, paths);
    }

}

Request封裝

package tech.pdai.springboot.api.sign.config;

import java.io.IOException;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;

@Slf4j
public class RequestCachingFilter extends OncePerRequestFilter {

    /**
     * This {@code doFilter} implementation stores a request attribute for
     * "already filtered", proceeding without filtering again if the
     * attribute is already there.
     *
     * @param request     request
     * @param response    response
     * @param filterChain filterChain
     * @throws ServletException ServletException
     * @throws IOException      IOException
     * @see #getAlreadyFilteredAttributeName
     * @see #shouldNotFilter
     * @see #doFilterInternal
     */
    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
        boolean isFirstRequest = !isAsyncDispatch(request);
        HttpServletRequest requestWrapper = request;
        if (isFirstRequest && !(request instanceof ContentCachingRequestWrapper)) {
            requestWrapper = new ContentCachingRequestWrapper(request);
        }
        try {
            filterChain.doFilter(requestWrapper, response);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

註冊

package tech.pdai.springboot.api.sign.config;

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FilterConfig {
    @Bean
    public RequestCachingFilter requestCachingFilter() {
        return new RequestCachingFilter();
    }

    @Bean
    public FilterRegistrationBean requestCachingFilterRegistration(
            RequestCachingFilter requestCachingFilter) {
        FilterRegistrationBean bean = new FilterRegistrationBean(requestCachingFilter);
        bean.setOrder(1);
        return bean;
    }
}

實現接口

package tech.pdai.springboot.api.sign.controller;

import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import tech.pdai.springboot.api.sign.config.response.ResponseResult;
import tech.pdai.springboot.api.sign.config.sign.Signature;
import tech.pdai.springboot.api.sign.entity.User;

/**
 * @author pdai
 */
@RestController
@RequestMapping("user")
public class SignTestController {

    @Signature
    @PostMapping("test/{id}")
    public ResponseResult<String> myController(@PathVariable String id
            , @RequestParam String client
            , @RequestBody User user) {
        return ResponseResult.success(String.join(",", id, client, user.toString()));
    }

}

接口測試

body參數

如果不帶X-SIGN

如果X-SIGN錯誤

如果X-SIGN正確

示例源碼

https://github.com/realpdai/tech-pdai-spring-demos

更多內容

告別碎片化學習,無套路一站式體系化學習後端開發: Java 全棧知識體系(https://pdai.tech)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章