在以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?ke...
伺服器端接收到請求後,用同樣的演算法獲得伺服器的sign,對比客戶端的sign是否一致,如果一致請求有效;如果不一致返回指定的錯誤資訊。
- 補充:對什麼簽名?
- 主要包括請求引數,這是最主要的部分,簽名的目的要防止引數被篡改,就要對可能被篡改的引數簽名;
- 同時考慮到請求引數的來源可能是請求路徑path中,請求header中,請求body中。
- 如果對客戶端分配了AppKey&AppSecret,也可加入簽名計算;
- 考慮到其它冪等,token失效等,也會將涉及的引數一併加入簽名,比如timestamp,流水號nonce等(這些引數可能來源於header)
- 補充: 簽名演算法?
一般涉及這塊,主要包含三點:金鑰,簽名演算法,簽名規則
- 金鑰secret: 前後端約定的secret,這裡要注意前端可能無法妥善儲存好secret,比如SPA單頁應用;
- 簽名演算法:也不一定要是對稱加密演算法,對稱是反過來解析sign,這裡是用同樣的演算法和規則計算出sign,並對比前端傳過來的sign是否一致。
- 簽名規則:比如多次加鹽加密等;
PS:有讀者會問,我們是可能從有些客戶端獲取金鑰,演算法和規則的(比如前端SPA單頁應用生成的js中獲取金鑰,演算法和規則),那麼簽名的意義在哪裡?我認為簽名是手段而不是目的,簽名是加大攻擊者攻擊難度的一種手段,至少是可以抵擋大部分簡單的攻擊的,再加上其它防範方式(流水號,時間戳,token等)進一步提升攻擊的難度而已。
- 補充:簽名和加密是不是一回事?
嚴格來說不是一回事:
- 簽名是通過對引數按照指定的演算法、規則計算出sign,最後前後端通過同樣的演算法計算出sign是否一致來防止引數篡改的,所以你可以看到引數是明文的,只是多加了一個計算出的sign。
- 加密是對請求的引數加密,後端進行解密;同時有些情況下,也會對返回的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/t...
更多內容
告別碎片化學習,無套路一站式體系化學習後端開發: Java 全棧知識體系(https://pdai.tech)