基于Token实现开放API接口签名验证
一、簽名機制簡介
1、如何保證數據在通信時的安全性
如果外部用戶需要訪問開放的 API接口,我們通過 HTTP Post或Get方式請求服務器,那么在寫對外開放的 API接口如何保證數據的安全性的?
在開發中,為了保證數據在通信時的安全性,我們可以采用參數簽名的方式來進行相關驗證。所以后端在開發對外開放的 API接口時,一般會對參數進行簽名來保證接口的安全性。
在設計簽名算法時,主要考慮這幾個問題:
基于這幾個問題,我們通過以下步驟來保證數據在通信時的安全性:
1.1 請求身份
通過給第三方開發者分配 AccessKey和 AccessKey Secret來驗證請求者身份。
- AccessKey ID:用于標識訪問者的身份,確保唯一。也可理解為用戶名
- AccessKey Secret:用于接口加密,確保不易被窮舉,生成算法不易被猜測。也可理解為用戶密碼
AccessKey ID和AccessKey Secret由 API服務方分配給訪問者,必須嚴格保密。
1.2 防止篡改 - 參數簽名
通過將請求的所有參數按照字母先后順序排序后拼接再根據簽名算法(比如MD5)加密得到新的字符串來保證請求參數不被篡改。
主要就是兩點:
- 構造用于簽名的規范字符串
- 將構造用于簽名的規范字符串通過簽名算法生成簽名值
1.3 重放攻擊
上面雖然解決了請求參數被篡改的隱患,但是還存在著重復使用請求參數偽造二次請求的隱患。
我們可以在請求里攜帶時間戳等參數來保證請求的唯一和過期或者重復的請求在指定時間內有效(可配置)。
1.3.1 timestamp+nonce方案
nonce指唯一的隨機字符串,用來標識每個被簽名的請求。
通過為每個請求提供一個唯一的標識符,服務器能夠防止請求被多次使用(記錄所有用過的 nonce以阻止它們被二次使用)。然而,對服務器來說永久存儲所有接收到的nonce的代價是非常大的。可以使用 timestamp來優化 nonce的存儲。
假設允許客戶端和服務端最多能存在15分鐘的時間差,同時追蹤記錄在服務端的 nonce集合。
當有新的請求進入時,
- 首先檢查攜帶的 timestamp是否在15分鐘內,如超出時間范圍,則拒絕,
- 然后查詢攜帶的 nonce,如存在已有集合,則拒絕。
- 否則,記錄該 nonce,并刪除集合內時間戳大于15分鐘的nonce(可以使用 redis的 expire,新增 nonce的同時設置它的超時失效時間為15分鐘)。
1.3.2 Token&AppKey(APP)
在 APP開放API接口的設計中,由于大多數接口涉及到用戶的個人信息以及產品的敏感數據,所以要對這些接口進行身份驗證,為了安全起見讓用戶暴露的明文密碼次數越少越好,然而客戶端與服務器的交互在請求之間是無狀態的,也就是說,當涉及到用戶狀態時,每次請求都要帶上身份驗證信息。
Token身份驗證:
- 用戶登錄向服務器提供認證信息(如賬號和密碼),服務器驗證成功后返回 Token給客戶端;
- 客戶端將 Token保存在本地,后續發起請求時,攜帶此 Token;
- 服務器檢查 Token的有效性,有效則放行,無效(Token錯誤或過期)則拒絕。
安全隱患:Token被劫持,偽造請求和篡改參數。《設計一個安全的對外接口》這篇也推薦閱讀一下。
1.3.3 Token+AppKey簽名驗證
與上面開發平臺的驗證方式類似,為客戶端分配 AppKey(密鑰,用于接口加密,不參與傳輸),將AppKey和所有請求參數組合成源串,根據簽名算法生成簽名值,發送請求時將簽名值一起發送給服務器驗證。
這樣,即使 Token被劫持,對方不知道 AppKey和簽名算法,就無法偽造請求和篡改參數。
再結合上述的重發攻擊解決方案,即使請求參數被劫持也無法偽造二次重復請求。
二、開放 API接口簽名驗證定義
通過對簽名機制的了解,我們自己實現一個 開放 API接口簽名驗證。
我們API接口采用 TOKEN授權機制 + AppKey簽名驗證來實現進行交互。
- 第三方在進行所有業務接口請求之前,必須先通過 API接口獲取到正確的授權碼(TOKEN)。
- 上面AccessKey ID和AccessKey Secret可以理解為 token授權機制的用戶名和密碼,變量名自定義(AppKey和Code等)。
- 簽名算法中 構造用于簽名的規范字符串的方式后端自定義。
1、請求
第三方在進行所有業務接口請求之前,必須先通過 API接口獲取到正確的授權碼(TOKEN)。
每個接口都有請求方式說明,主要使用 get、post進行數據交互。所有接口采取 utf-8字符集 發送。
- get請求時,系統級參數和應用參數都以 get參數方式簽名并發送。
- post請求時,系統級參數以 get參數方式,應用參數都以 post參數方式簽名并發送。
具體請求參數請參見各接口說明。
2、請求參數
系統級參數:
appKey:用于標識訪問者的身份,即用戶名 format:響應數據格式 signMethod:簽名算法 signVersion:簽名版本 timestamp:請求時間 nonce:指唯一的隨機字符串(比如uuid),用來標識每個被簽名的請求 version:接口版本 sign:API 簽名值 token:授權碼(部分接口不需要,比如獲取授權碼。詳見各接口定義)應用級參數:見各接口規定的參數。
3、簽名驗簽算法設計
3.1 簽名生成算法
簽名生成算法步驟如下:
HTTP請求的構造用于簽名的規范字符串(StringToSign)偽代碼如下:
StringToSign =HTTPRequestMethod + '\n' +CanonicalURI + '\n' +CanonicalQueryString + '\n' +Token + '\n' +HexEncode(Hash(RequestPayload))參數說明:
(1)HTTPRequestMethod的值
HTTP請求方法的值,如GET、PUT、POST等。以換行符結束。
(2)CanonicalURI的值
規范URI參數(CanonicalURI)的值,以換行符結束。
規范URI,即請求資源路徑,是 URI的絕對路徑部分的URI編碼。
格式:
根據 RFC 3986標準化URI路徑,移除冗余和相對路徑部分,路徑中每個部分必須為URI編碼。如果URI路徑不以“/”結尾,則在尾部添加“/”。
(3)CanonicalQueryString的值
將 GET參數通過西面要求拼接生成規范查詢字符串(CanonicalQueryString)的值,以換行符結束。
查詢字符串,即查詢參數或者 GET參數。如果沒有查詢參數,則為空字符串。
格式:
規范查詢字符串需要滿足以下要求:
- 根據以下規則對每個參數名和值進行 URI編碼:
- 請勿對RFC 3986定義的任何非預留字符進行URI編碼,這些字符包括:A-Z、a-z、0-9、-、_、.和~。
- 使用%XY對所有非預留字符進行百分比編碼,其中X和Y為十六進制字符(0-9和A-F)。例如,空格字符必須編碼為%20,擴展UTF-8字符必須采用“%XY%ZA%BC”格式。
- 對于每個參數,追加“URI編碼的參數名稱=URI編碼的參數值”。如果沒有參數值,則以空字符串代替,但不能省略“=”。
注意:這里我們定義了系統級參數與應用級參數,根據各接口規定的參數和 GET請求方式,合理的將系統級參數與應用級參數合并,來拼接查詢字符串。
(4)Token的值
通過 API接口獲取到正確的授權碼(TOKEN)的值,以換行符結束。
(5)HexEncode(Hash(RequestPayload))的值
使用 SHA 256哈希函數請求正文中的 body體(RequestPayload),生成的小寫哈希值。如果 RequestPayload為空或者 NULL時,默認空字符串來處理。
釋義:
請求消息體。消息體需要做兩層轉換:HexEncode(Hash(RequestPayload)),其中:
- Hash表示生成消息摘要的函數,當前支持SHA-256算法。
- HexEncode表示以小寫字母形式返回摘要的 Base-16編碼的函數。
例如,HexEncode(“m”) 返回值為“6d”而不是“6D”。輸入的每一個字節都表示為兩個十六進制字符。
注意:
- 各個參數值之間使用 換行符連接,或者你可以使用其他符合都可以。
- 默認最后一行參數不需要添加換行符’\n’。
上面生成構造用于簽名的規范字符串 參考了華為云,你也可以自定義生成規則。
示例:
3.2 簽名驗證算法(后端)
接口提供方驗證接口請求是否可信,主要算法跟生成API簽名的算法是一樣的。
簽名驗證算法步驟如下:
4、響應
接口以 JSON數據格式響應,響應的固定參數格式為:
{"success": true|false,"errorMessage": "失敗時錯誤信息","resultData": "返回結果集" }具體響應結果集請參見各接口說明。
5、其他
注意:
- 授權碼(TOKEN),授權時長為一天。
- API接口中的地址、appKey、appKeyCode 為接口方提供,對接方請勿泄露,否則后果自付。
6、API接口參數說明
這類列舉一下 獲取授權碼(TOKEN)接口參數說明,其他接口根據業務自己定義。
6.1 獲取授權碼(TOKEN)接口
獲取接口授權碼,在調用其他業務接口前,必須通過該接口獲取授權碼。
- 請求地址:xxxx
- 請求方式:GET
- 請求參數:?appKey=zhaoyun&appKeySecret=zhaoyun123456
- 響應結果:
注意:
- 授權碼有效期為:1天。
- 每調用一次或刷新后,舊的授權碼(TOKEN)將失效。
三、Java代碼實現
創建 maven 項目,下面貼一些主要代碼。
1、自定義BodyReaderFilter
解決 springboot 對請求消息體中流不可重復讀取問題。
@WebFilter(filterName = "bodyReaderFilter", urlPatterns = "/*") public class BodyReaderFilter implements Filter {@Overridepublic void init(FilterConfig filterConfig) throws ServletException {// do nothing}@Overridepublic void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {ServletRequest requestWrapper = null;if (request instanceof HttpServletRequest) {// 將請求對象包裝為 可重復讀取流的請求對象。注意:構造好了,但是需要在攔截器中獲取requestWrapper = new BodyReaderHttpServletRequestWrapper((HttpServletRequest) request);}if (requestWrapper == null) {chain.doFilter(request, response);} else {chain.doFilter(requestWrapper, response);}}@Overridepublic void destroy() {// do nothing} }public class BodyReaderHttpServletRequestWrapper extends HttpServletRequestWrapper {private byte[] requestBody = null;// 用于將流保存下來public BodyReaderHttpServletRequestWrapper(HttpServletRequest request) throws IOException {super(request);requestBody = StreamUtils.copyToByteArray(request.getInputStream());}@Overridepublic ServletInputStream getInputStream() throws IOException {final ByteArrayInputStream bais = new ByteArrayInputStream(requestBody);return new ServletInputStream() {@Overridepublic int read() throws IOException {return bais.read();}@Overridepublic boolean isFinished() {return false;}@Overridepublic boolean isReady() {return false;}@Overridepublic void setReadListener(ReadListener readListener) {}};}@Overridepublic BufferedReader getReader() throws IOException {return new BufferedReader(new InputStreamReader(getInputStream()));}}2、自定義攔截器
實現 簽名認證攔截。這里沒有進行方法封裝,步驟寫的很清晰。
public class SignInterceptor implements HandlerInterceptor {Logger logger = LoggerFactory.getLogger(SignInterceptor.class);@Autowiredprivate AppTokenService appTokenService;@Autowiredprivate NonceService nonceService;/*** 15分鐘*/private static final Long FIFTEEN_MINUTES = 1000 * 60 * 15L;@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {BaseResult baseResult = new BaseResult();request.setCharacterEncoding("UTF-8");String method = request.getMethod();StringBuffer requestURL = request.getRequestURL();String canonicalURI = requestURL.substring(requestURL.indexOf(request.getContextPath()));if (!canonicalURI.endsWith("/")) {canonicalURI = canonicalURI + "/";}/*** 簽名驗證算法步驟如下: <br/>* 1. 得到客戶端請求攜帶的 API簽名值(API 的簽名值),非空判斷。 <br/>* 2. 檢查 Token授權碼的有效性 <br/>* 3. 檢查攜帶的 timestamp的有效性 <br/>* 4. 檢查攜帶的 nonce的唯一性 <br/>* 5. 服務器端根據請求方攜帶的參數(注意:不包括sign參數)通過簽名生成算法生成 API簽名值。 <br/>* 6. 開始簽名驗證,如果服務器端生成的 API簽名值與客戶端請求的API簽名是否一致的。如果一致,則請求是可信的,放行通過;否則就是不可信的,拒絕訪問。 <br/>*/// 獲取請求參數Map<String, String[]> parameterMap = request.getParameterMap();// 1. 得到客戶端請求攜帶的 API簽名值(API 的簽名值),非空判斷。if (!parameterMap.containsKey("sign")) {baseResult.setSuccess(false);baseResult.setErrorMessage("簽名(sign)請求參數缺失");responseOutByJson(response, baseResult);return false;}String[] signArr = parameterMap.get("sign");String sign = null;if (signArr == null || StringUtils.isBlank(sign = signArr[0])) {baseResult.setSuccess(false);baseResult.setErrorMessage("簽名(sign)請求參數值為空");responseOutByJson(response, baseResult);return false;}// 2. 檢查 Token授權碼的有效性(獲取授權碼接口不判斷)String token = "";if(!canonicalURI.startsWith(request.getContextPath() + "/sign/getToken.json")){// 如果header中不存在token,則從參數中獲取tokentoken = request.getHeader("token");if (StringUtils.isBlank(token)) {token = request.getParameter("token");}if (StringUtils.isBlank(token)) {baseResult.setSuccess(false);baseResult.setErrorMessage("token請求參數缺失,或者值為空");responseOutByJson(response, baseResult);}// 查詢token信息AppToken appToken = appTokenService.queryByToken(token);if (appToken == null || appToken.getTokenExpireTime() < System.currentTimeMillis()) {baseResult.setSuccess(false);baseResult.setErrorMessage("token已過期,請重新登錄");responseOutByJson(response, baseResult);}}// 3. 檢查攜帶的 timestamp的有效性// 當前請求時間戳String timestamp = parameterMap.get("timestamp")[0];if (timestamp == null) {baseResult.setSuccess(false);baseResult.setErrorMessage("請求參數(timestamp)缺失,請檢查后再試");responseOutByJson(response, baseResult);}long now = System.currentTimeMillis();// 判斷 timestamp是否在規定時間范圍內 5分鐘 如超出時間范圍,則拒絕if (now - Long.parseLong(timestamp) >= FIFTEEN_MINUTES) {baseResult.setSuccess(false);baseResult.setErrorMessage("請求超時");responseOutByJson(response, baseResult);}// 4. 檢查攜帶的 nonce的唯一性// 查詢攜帶的隨機字符串nonceString nonce = parameterMap.get("nonce")[0];if (nonce == null) {baseResult.setSuccess(false);baseResult.setErrorMessage("請求參數(nonce)缺失,請檢查后再試");responseOutByJson(response, baseResult);}// 從緩存中查找是否有相同請求,,如存在已有集合,則拒絕if (nonceService.isExist(nonce)) {baseResult.setSuccess(false);baseResult.setErrorMessage("nonce已存在,請求錯誤,請檢查后再試");responseOutByJson(response, baseResult);} else {// 否則,記錄該nonce,并刪除集合內時間戳大于5分鐘的nonce,新增nonce的同時設置它的超時失效時間為5分鐘nonceService.saveNonceAndDeleteExpireTime(nonce);}// 5. 服務器端根據請求方攜帶的參數(注意:不包括sign參數)通過簽名生成算法生成 API簽名值。// 獲取請求消息體String requestBody = getRequestBody(request);Map<String, String> getParameterMap = parameterMap.entrySet().stream().filter(m -> !"sign".equals(m.getKey())).collect(Collectors.toMap(m -> m.getKey(), m -> m.getValue()[0], (o, n) -> n));getParameterMap.put("token", token);// 生成sign,進行簽名認證String genSign = SignUtils.generateSign(method, canonicalURI, getParameterMap, token, requestBody);logger.error("----preHandle---- -> sign={},genSign={}", sign, genSign);if (!sign.equals(genSign)) {baseResult.setSuccess(false);baseResult.setErrorMessage("簽名(sign)不匹配, 簽名驗證失敗");responseOutByJson(response, baseResult);return false;}return true;}/*** 獲取請求消息體** @param request* @return*/private String getRequestBody(HttpServletRequest request) {StringBuilder sb = new StringBuilder("");try (BufferedReader br = request.getReader()) {String str;while ((str = br.readLine()) != null) {sb.append(str);}} catch (IOException e) {logger.error("系統異常 -> 獲取請求消息體參數異常。e={}", e.getMessage());}return sb.toString();}/*** 響應輸出json* * @param response* @param baseResult*/private void responseOutByJson(HttpServletResponse response, BaseResult baseResult) {response.setCharacterEncoding("UTF-8");response.setContentType("application/json");try (PrintWriter out = response.getWriter()) {out.print(JSON.toJSONString(baseResult, SerializerFeature.WriteNonStringValueAsString, SerializerFeature.WriteMapNullValue));} catch (IOException e) {logger.error("系統異常 -> 響應異常。e={}", e.getMessage());}} }3、簽名算法工具類
public class SignUtils {private static final Logger logger = LoggerFactory.getLogger(SignUtils.class);private SignUtils() {}/*** 生成簽名* * @param httpRequestMethod* @param canonicalURI* @param getParamterMap* @param token* @param requestBodyStr* @return*/public static String generateSign(String httpRequestMethod, String canonicalURI, Map<String, String> getParamterMap, String token, String requestBodyStr) {/*** 簽名生成算法步驟如下: <br/>* 1. 生成構造用于簽名的規范字符串(StringToSign)。 <br/>* 2. 將 StringToSign字符串通過簽名算法(這里使用 MD5)生成簽名值,并將簽名值轉成為大寫,然后再進行Base64編碼。即得到最終 API的簽名值。 <br/>**/String stringToSign = structureStringToSign(httpRequestMethod, canonicalURI, getParamterMap, token, requestBodyStr);// MD5后轉成大寫即為最終簽名結果。String sign = Md5Utils.MD5Upper(stringToSign).toUpperCase();logger.info("簽名算法,生成 API的簽名值 -> sign={}", sign);return sign;}/*** 構造用于簽名的規范字符串** @param httpRequestMethod* @param canonicalURI* @param getParamterMap* @param token* @param requestBodyStr* @return*/private static String structureStringToSign(String httpRequestMethod, String canonicalURI, Map<String, String> getParamterMap, String token, String requestBodyStr) {/*** StringToSign = <br/>* HTTPRequestMethod + '\n' + <br/>* CanonicalURI + '\n' + <br/>* CanonicalQueryString + '\n' + <br/>* Token + '\n' + <br/>* HexEncode(Hash(RequestPayload)) <br/>*/String canonicalQueryString = spliceCanonicalQueryString(getParamterMap);// 根據RFC 3986標準化URI路徑,移除冗余和相對路徑部分,路徑中每個部分必須為URI編碼。如果URI路徑不以“/”結尾,則在尾部添加“/”。if (!canonicalURI.endsWith("/")) {canonicalURI = canonicalURI + "/";}// 如果請求消息體為null,直接使用空字符串""。SHA256 哈希,并小寫String sha256RequestBody = sha256RequestBody = Sha256Utils.getSHA256(StringUtils.isBlank(requestBodyStr) ? "" : requestBodyStr).toLowerCase();;// 構建規范字符串StringBuffer stringToSign = new StringBuffer("");stringToSign.append(httpRequestMethod.toUpperCase()).append("\n").append(canonicalURI).append("\n").append(canonicalQueryString).append("\n").append(token).append("\n").append(sha256RequestBody);logger.info("生成構造用于簽名的規范字符串 -> canonicalQueryString={}, stringToSign={}", canonicalQueryString, stringToSign);return stringToSign.toString();}/*** 獲取拼接生成規范查詢字符串,不帶sign** @param getParamterMap* - 系統級參數與應用級參數合并之后的集合* @return*/public static String spliceCanonicalQueryString(Map<String, String> getParamterMap) {if (null == getParamterMap) {return null;}// 字典排序TreeMap<String, String> sortMap = new TreeMap<>(getParamterMap);return spliceParams(sortMap);}/*** 拼接參數** @param treeMap* @return*/private static String spliceParams(TreeMap<String, String> treeMap) {if (null == treeMap) {return null;}StringBuilder paramStr = new StringBuilder();/*** 去除首尾空格,符合URL編碼的編碼規則*/treeMap.forEach((key, value) -> {key = key.trim();key = URLEncoder.encode(key, StandardCharsets.UTF_8).replace("*", "%2A").replace("+", "%20").replace("%7E", "~");value = StringUtils.isBlank(value) ? "" : value.trim();value = URLEncoder.encode(value, StandardCharsets.UTF_8).replace("*", "%2A").replace("+", "%20").replace("%7E", "~");paramStr.append("&").append(key).append("=").append(value);});// 去掉第一個&return paramStr.substring(1);}}4、配置類
@Configuration public class WebConfig implements WebMvcConfigurer {@Beanpublic SignInterceptor signInterceptor() {return new SignInterceptor();}/*** 添加攔截器 https://blog.csdn.net/qq_42240485/article/details/104900009* * @param registry*/@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(signInterceptor()).addPathPatterns("/sign/**").excludePathPatterns("/login", "/", "/index");}/*** 添加過濾器* * @return*/@Beanpublic FilterRegistrationBean<BodyReaderFilter> Filters() {FilterRegistrationBean<BodyReaderFilter> registrationBean = new FilterRegistrationBean<BodyReaderFilter>();registrationBean.setFilter(new BodyReaderFilter());registrationBean.addUrlPatterns("/*");registrationBean.setName("bodyReaderFilter");return registrationBean;}}參考文章:
- 阿里云-簽名機制:https://help.aliyun.com/document_detail/44396.html
- 華為云-AK/SK簽名認證流程:https://support.huaweicloud.com/devg-apisign/api-sign-algorithm.html
- 開放 API接口簽名驗證:https://blog.csdn.net/yonhu123java/article/details/108483494
– 求知若饑,虛心若愚。
總結
以上是生活随笔為你收集整理的基于Token实现开放API接口签名验证的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: d-s证据理论 matlab代码2
- 下一篇: Composition API 使用