在以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是否一致,如果一致請求有效;如果不一致返回指定的錯誤信息。
- 補充:對什麼簽名?
- 主要包括請求參數,這是最主要的部分,簽名的目的要防止參數被篡改,就要對可能被篡改的參數簽名;
- 同時考慮到請求參數的來源可能是請求路徑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/tech-pdai-spring-demos
更多內容
告別碎片化學習,無套路一站式體系化學習後端開發: Java 全棧知識體系(https://pdai.tech)