SpringBoot介面

2022-07-18 09:01:03

在以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)