一、簡介
首先談談什麼是接口安全問題?接口安全,其實就是保證自己應用程序對外暴露接口的安全,即我這個接口只能某些第三方應用進行訪問,不應該被別人隨意訪問。
服務端對外開放API接口,必須關注接口安全性的問題,要確保第三方應用程序與API接口之間的安全通信,防止數據被惡意篡改、僞造參數等攻擊。
常見保證接口安全的方式有下面幾種方式:
【a】簽名驗證方式(也叫url簽名算法,本篇文章以本方式爲例 ):服務端從某種層面來說需要驗證接受到數據是否和客戶端發來的數據是否一致,要驗證數據在傳輸過程中有沒有被注入攻擊。這時候客戶端和服務端就有必要做簽名和驗籤。具體做法: 客戶端對所有請求服務端接口參數做加密生成簽名,並將簽名作爲請求參數一併傳到服務端,服務端接受到請求同時要做驗籤的操作,對稱加密對請求參數生成簽名,並與客戶端傳過來的簽名進行比對,如簽名不一致,服務端需要攔截該請求;如果驗證簽名成功,才能放行接口。
【b】HTTPS
HTTPS能夠有效防止中間人攻擊,有效保證接口不被劫持,對數據竊取篡改做了安全防範。但HTTP升級HTTPS會帶來更多的握手,而握手中的運算會帶來更多的性能消耗。
【c】Token機制
具體的做法: 在用戶成功登錄時,系統可以返回客戶端一個Token,後續客戶端調用服務端的接口,都需要帶上Token,而服務端需要校驗客戶端Token的合法性,是否超時過期等。Token不一致的情況下,服務端需要攔截該請求。
二、具體實現步驟
假設請求接口地址爲: localhost:1111/getAllStudent ----POST方式
RequestBody請求參數: {noncestr=e697ea7d-27c3-42a5-a421-2aa3d7d58efc, timestamp=1590375647, signature=CC78E603495F5AB72727024F4EFF1168}
下面得區分客戶端和服務器端:
(一)客戶端
【a】生成當前時間戳timestamp=now: 可以使用下面的方法生成。
/**
* 獲取時間戳
*/
private static String create_timestamp() {
return Long.toString(System.currentTimeMillis() / 1000);
}
【b】生成唯一隨機字符串noncestr=random:可以使用下面的方法生成。
/**
* 獲取隨機字符串
*/
private static String create_nonce_str() {
return UUID.randomUUID().toString();
}
【c】按照請求參數名的字母升序排列非空請求參數(包含appKey、secretKey、noncestr和timestamp等). 【使用URL鍵值對的格式(即key1=value1&key2=value2…)拼接成字符串string】
string="appkey=STUDENT_LIST_SAFE_INTERFACE_APP_KEY&noncestr=bc30b801-2683-4f09-baf5-890ee53f8a3a&secretkey=f1e4cd8ca987f11f2a773aa021316159×tamp=1590719955";
【d】使用MD5算法對上面拼接成功的string進行加密,並轉換爲大寫(這裏也可以使用SHA1算法等其他加密方式)。加密完成後生成signature簽名。
signature = MD5(string).toUpperCase();
【e】將簽名signature、隨機字符串noncestr、隨機時間戳timestamp作爲參數傳遞到服務器端。
以上幾個步驟就是客戶端需要做的五件事情,下面聊聊服務器端需要怎麼驗證客戶端發送過來的簽名是否正確。
(二)服務器端
以一張圖講述大體驗證簽名的過程:
三、示例
下面通過一個SpringBoot項目簡單說明簽名驗證的實現步驟,爲了防止程序堆棧異常信息暴露給第三方應用,我們服務器端需要構建異常統一處理框架,將服務可能出現的異常做統一封裝,返回固定的code與msg。
【a】創建一個SpringBoot項目作爲服務器端,也就是暴露接口給第三方的服務,pom.xml依賴文件如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.11.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.wsh.springboot</groupId>
<artifactId>springboot-redis</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springboot-redis</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.58</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
【b】定義開放接口返回狀態代碼的枚舉類:用於包裝接口訪問的一些錯誤代碼
/**
* 接口返回狀態代碼的枚舉類
*
* @author weixiaohuai
*/
public enum CommonResultCodeEnum {
/**
* 操作成功 {@code code=1}
*/
SUCCESS(1, "操作成功"),
/**
* 操作失敗,請稍候再試 {@code code=0}
*/
FATAL(0, "操作失敗,請稍候再試"),
/**
* 參數解析失敗 {@code code=40000}
*/
PARAMETER_ANALYSIS_FAILURE(40000, "參數解析失敗"),
/**
* 接口授權認證失敗,簽名不匹配 {@code code=40001}
*/
SIGNATURE_MISMATCH(40001, "接口授權認證失敗,簽名不匹配"),
/**
* 參數不足 {@code code=40002}
*/
NULL_PARAMS_DATA(40002, "參數不足"),
/**
* 參數值爲空 {@code code=40003}
*/
NULL_PARAMS_VALUE(40003, "參數值爲空"),
/**
* 缺少timestamp參數 {@code code=40004}
*/
MISSING_TIMESTAMP(40004, "缺少timestamp參數"),
/**
* 缺少nonceStr參數 {@code code=40005}
*/
MISSING_NONCESTR(40005, "缺少nonceStr參數"),
/**
* 缺少appkey參數 {@code code=40006}
*/
MISSING_APPKEY(40006, "缺少appKey參數"),
/**
* 缺少secretkey參數 {@code code=40007}
*/
MISSING_SECRETKEY(40007, "缺少secretKey參數"),
/**
* 服務器執行錯誤 {@code code=50000}
*/
ERROR(50000, "服務器執行錯誤"),
/**
* 請求數據不存在,或數據集爲空 {@code code=60000}
*/
NULL_RESULT_DATA(60000, "請求數據不存在,或數據集爲空");
private int value;
private String text;
private CommonResultCodeEnum(int value, String text) {
this.value = value;
this.text = text;
}
/**
* 獲取value
*
* @return int
*/
public int getValue() {
return value;
}
/**
* 設置 value
*
* @param value int
*/
public void setValue(int value) {
this.value = value;
}
/**
* 獲取text
*
* @return String
*/
public String getText() {
return text;
}
/**
* 設置 text
*
* @param text String
*/
public void setText(String text) {
this.text = text;
}
}
【c】對外開放接口統一返回結果包裝類:包裝返回結果,防止暴露異常信息出去。
package com.wsh.springboot.springbootredis.common;
/**
* 對外接口統一返回JSON結果包裝類
*
* @author weixiaohuai
*/
public class CommonApiResult {
/**
* 是否成功標誌,1:成功,0:失敗
*/
private Integer isSuccess;
/**
* 返回業務數據
*/
private Object data;
/**
* 返回消息內容
*/
private String resMsg;
/**
* 返回結果代碼
*/
private Integer resCode;
/**
* 默認構造方法
*/
public CommonApiResult() {
this.resCode = CommonResultCodeEnum.SUCCESS.getValue();
this.resMsg = CommonResultCodeEnum.SUCCESS.getText();
if (this.resCode == 1) {
this.isSuccess = 1;
} else {
this.isSuccess = 0;
}
}
public CommonApiResult(CommonResultCodeEnum commonresultcodeenum) {
this.resCode = commonresultcodeenum.getValue();
this.resMsg = commonresultcodeenum.getText();
if (this.resCode == 1) {
this.isSuccess = 1;
} else {
this.isSuccess = 0;
}
}
public Integer getIsSuccess() {
return isSuccess;
}
public void setIsSuccess(Integer isSuccess) {
this.isSuccess = isSuccess;
}
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
public String getResMsg() {
return resMsg;
}
public void setResMsg(String resMsg) {
this.resMsg = resMsg;
}
public Integer getResCode() {
return resCode;
}
public void setResCode(Integer resCode) {
this.resCode = resCode;
}
@Override
public String toString() {
return "CommonApiResult{" +
"isSuccess=" + isSuccess +
", data=" + data +
", resMsg='" + resMsg + '\'' +
", resCode=" + resCode +
'}';
}
}
【d】定義接口驗證簽名工具類:用於服務器端驗證簽名相關的算法
package com.wsh.springboot.springbootredis.common;
import org.apache.commons.lang.StringUtils;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
/**
* @Description: 接口驗證簽名工具類
* @author: weishihuai
* @Date: 2020/5/25 09:59
*/
public class SignatureUtils {
/**
* 應用唯一標識
*/
private static final String APP_KEY = "appkey";
/**
* 應用祕鑰
*/
private static final String SECRET_KEY = "secretkey";
/**
* 時間戳
*/
private static final String TIMESTAMP = "timestamp";
/**
* 隨機字符串
*/
private static final String NONCE_STR = "noncestr";
//params: {noncestr=e697ea7d-27c3-42a5-a421-2aa3d7d58efc, timestamp=1590375647, signature=CC78E603495F5AB72727024F4EFF1168}
/**
* 驗證簽名算法
*
* @param params 第三方傳遞過來的驗證簽名的參數,包括簽名、隨機字符串、時間戳等
* @param appKey 應用唯一標識
* @param secretKey 應用祕鑰
* @return 狀態碼
*/
public static CommonResultCodeEnum checkInterfaceSignature(Map<String, Object> params, String appKey, String secretKey) {
// 1. 驗證參數
if (null == params || params.isEmpty() || StringUtils.isBlank(appKey) || StringUtils.isBlank(secretKey)) {
return CommonResultCodeEnum.NULL_PARAMS_DATA;
}
//第三方傳入的簽名
String signature = null != params.get("signature") ? params.get("signature").toString() : "";
//2. 驗證是否缺少驗籤所需參數
Set<String> keys = params.keySet();
Iterator<String> iterator = keys.iterator();
//缺少隨機時間戳參數
if (!keys.contains(TIMESTAMP)) {
return CommonResultCodeEnum.MISSING_TIMESTAMP;
}
//缺少隨機字符串參數
if (!keys.contains(NONCE_STR)) {
return CommonResultCodeEnum.MISSING_NONCESTR;
}
//循環校驗參數的值是否爲空
while (iterator.hasNext()) {
String paramName = iterator.next();
Object paramValue = params.get(paramName);
if ((TIMESTAMP.equals(paramName) || NONCE_STR.equals(paramName)) && null == paramValue) {
return CommonResultCodeEnum.NULL_PARAMS_VALUE;
}
}
String[] signatureParamsKeys = new String[]{APP_KEY, SECRET_KEY, TIMESTAMP, NONCE_STR};
// 3.對參數進行排序
sort(signatureParamsKeys);
//組裝參與簽名生成的字符串(使用 URL 鍵值對的格式(即key1=value1&key2=value2…)拼接成字符串)
StringBuilder string = new StringBuilder();
for (int i = 0; i < signatureParamsKeys.length; i++) {
if (APP_KEY.equals(signatureParamsKeys[i])) {
if (StringUtils.isBlank(appKey)) {
return CommonResultCodeEnum.MISSING_APPKEY;
} else {
string.append(signatureParamsKeys[i]).append("=").append(appKey);
}
} else if (SECRET_KEY.equals(signatureParamsKeys[i])) {
if (StringUtils.isBlank(secretKey)) {
return CommonResultCodeEnum.MISSING_SECRETKEY;
} else {
string.append(signatureParamsKeys[i]).append("=").append(secretKey);
}
} else {
//拼接隨機字符串、時間戳參數
String paramValue = null != params.get(signatureParamsKeys[i]) ? params.get(signatureParamsKeys[i]).toString() : "";
string.append(signatureParamsKeys[i]).append("=").append(paramValue);
}
if (i != signatureParamsKeys.length - 1) {
string.append("&");
}
}
// 4.生成加密簽名
String mySignature = MD5(string.toString()).toUpperCase();
// 5.校驗簽名是否一致
if (StringUtils.isNotBlank(mySignature) && mySignature.equals(signature)) {
return CommonResultCodeEnum.SUCCESS;
}
return CommonResultCodeEnum.SIGNATURE_MISMATCH;
}
/**
* 字典排序算法
*
* @param strArr 待排序數組
*/
public static void sort(String[] strArr) {
for (int i = 0; i < strArr.length - 1; i++) {
for (int j = i + 1; j < strArr.length; j++) {
if (strArr[j].compareTo(strArr[i]) < 0) {
String temp = strArr[i];
strArr[i] = strArr[j];
strArr[j] = temp;
}
}
}
}
/**
* MD5加密
*
* @param plainText 待加密字符串
* @return
*/
public static String MD5(String plainText) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(plainText.getBytes());
byte[] b = md.digest();
int i;
StringBuilder buf = new StringBuilder();
for (byte b1 : b) {
i = b1;
if (i < 0) {
i += 256;
}
if (i < 16) {
buf.append("0");
}
buf.append(Integer.toHexString(i));
}
// 32位加密
return buf.toString();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
return null;
}
}
}
可見,上面使用了MD5加密方式,當前可以根據項目要求靈活選擇其他加密算法。
【e】定義對外開放接口類
package com.wsh.springboot.springbootredis.controller;
import com.wsh.springboot.springbootredis.common.CommonApiResult;
import com.wsh.springboot.springbootredis.common.CommonResultCodeEnum;
import com.wsh.springboot.springbootredis.common.SignatureUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @Description: 對外開放接口類
* @author: weishihuai
* @Date: 2019/12/19 16:26
*/
@RestController
public class StudentController {
private static final Logger logger = LoggerFactory.getLogger(StudentController.class);
private static List<Map<String, Object>> studentList = new ArrayList<>();
static {
Map<String, Object> map;
for (int i = 1; i <= 5; i++) {
map = new HashMap<>();
map.put("name", "學生" + i);
map.put("sex", i % 2 == 0 ? "male" : "female");
map.put("age", i + 20);
studentList.add(map);
}
}
/**
* 查詢所有學生信息
*
* @param params 查詢參數
* @return
*/
@RequestMapping(value = "/getAllStudent", method = RequestMethod.POST)
public CommonApiResult getAllStudent(@RequestBody Map<String, Object> params) {
try {
/**
* 正常情況下, appKey和secretKey應該要弄成系統參數配置項進行配置或者從數據庫中讀取
* 這裏爲了演示方便,直接寫死.
*/
//開發接口應用唯一標識
String appKey = "STUDENT_LIST_SAFE_INTERFACE_APP_KEY";
//開發接口祕鑰
String secretKey = "f1e4cd8ca987f11f2a773aa021316159";
//驗證簽名, 驗籤通過,接口放行; 驗籤失敗,直接返回錯誤碼
CommonResultCodeEnum resultCode = SignatureUtils.checkInterfaceSignature(params, appKey, secretKey);
// 驗證簽名通過
if (resultCode.getValue() == 1) {
logger.info("接口驗證通過......");
//返回結果
CommonApiResult commonApiResult;
//這裏使用模擬數據返回,正常這裏應該寫具體的業務邏輯
if (null != studentList && studentList.size() > 0) {
commonApiResult = new CommonApiResult(CommonResultCodeEnum.SUCCESS);
//將業務數據包裝返回數據
commonApiResult.setData(studentList);
} else {
commonApiResult = new CommonApiResult(CommonResultCodeEnum.NULL_RESULT_DATA);
}
return commonApiResult;
} else {
logger.info("接口驗證失敗......");
// 驗證簽名失敗,直接返回錯誤碼
return new CommonApiResult(resultCode);
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
return new CommonApiResult(CommonResultCodeEnum.ERROR);
}
}
}
以上就是服務器端的一些關鍵點,主要關注簽名的驗證方面。下面我們寫一個測試類簡單測試一下我們開放接口是否安全。
【f】測試類
@RequestMapping("/test")
public String test() {
Map<String, Object> params = new HashMap<>();
params.put("noncestr", "e697ea7d-27c3-42a5-a421-2aa3d7d58efc");
params.put("timestamp", "1590375647");
params.put("signature", "CC78E603495F5AB72727024F4EFF1168");
//爲了演示方便,正常項目中應該是發送HTTP請求進行調用,這裏主要關注驗證簽名這一塊
this.getAllStudent(params);
return "success";
}
【g】測試結果
瀏覽器訪問:http://localhost:1111/test
觀察後端服務日誌,可見接口驗證簽名通過,返回的數據也正常包裝返回。
下面模擬一下接口驗證不通過的場景,修改下測試類:隨便寫了一個時間戳參數1111111111.
@RequestMapping("/test")
public String test() {
Map<String, Object> params = new HashMap<>();
params.put("noncestr", "e697ea7d-27c3-42a5-a421-2aa3d7d58efc");
params.put("timestamp", "1111111111");
params.put("signature", "CC78E603495F5AB72727024F4EFF1168");
this.getAllStudent(params);
return "success";
}
訪問http://localhost:1111/test,觀察後臺日誌。
以上就是關於開放接口安全性--驗證簽名算法方式的講解,可能還可以進一步優化,
【a】比如加入接口被調用的閾值限制,對接口訪問頻率設置一定閾值,對超過閾值的請求進行屏蔽及預警,防止我們的接口被玩壞。
【b】怎麼防止請求被多次使用。
【c】白名單機制:就是指定一些可以訪問我們暴露接口的域名,不在白名單裏面的域名發過來的請求,直接拒絕。
還有一種更加安全的處理方式,大體步驟如下:
- 首先判斷請求參數是否缺失,是否包含timestamp,token,signature,noncestr參數,如果參數缺失直接返回錯誤碼;
- 其次判斷服務器接到請求的時間和參數中的時間戳是否相差很長一段時間(時間自定義如10分鐘),如果超過10分鐘則說明該url已經過期(如果url被盜,改變了時間戳,會導致簽名生成的signature不同,驗籤失敗);
- 接着判斷token是否有效,根據請求過來的token,查詢redis緩存中是否存在該用戶對應的token,如果獲取不到,說明該token已過期,無法請求;
- 根據客戶端提交過來的url參數,服務器端按照同樣的規則生成signature簽名,對比簽名看是否相等,相等則放行;
流程圖大體如下: