如何設計一個安全好用的 OpenApi

爲了確保軟件接口的標準化和規範化,實現業務模塊的重用性和靈活性,並提高接口的易用性和安全性,OpenAPI 規範應運而生。這一規範通過制定統一的接口協議,規定了接口的格式、參數、響應和使用方法等內容,從而提高了接口的可維護性和可擴展性。同時,爲了也需要考慮接口的安全性和穩定性,本文將針對這些方面介紹一些具體的實踐方式。

一、AppId 和 AppSecret

AppId 的使用

AppId作爲一種全局唯一的標識符,其作用主要在於方便用戶身份識別以及數據分析等方面。爲了防止其他用戶通過惡意使用別人的AppId來發起請求,一般都會採用配對AppSecret的方式,類似於一種密碼。AppIdAppSecret通常會組合生成一套簽名,並按照一定規則進行加密處理。在請求方發起請求時,需要將這個簽名值一併提交給提供方進行驗證。如果簽名驗證通過,則可以進行數據交互,否則將被拒絕。這種機制能夠保證數據的安全性和準確性,提高系統的可靠性和可用性。

AppId 的生成

正如前面所說,AppId就是有一個身份標識,生成時只要保證全局唯一即可。

AppSecret 生成

AppSecret就是密碼,按照一般的的密碼安全性要求生成即可。

二、sign 簽名

RSASignature

首先,在介紹簽名方式之前,我們必須先了解 2 個概念,分別是:非對稱加密算法(比如:RSA)、摘要算法(比如:MD5)。

簡單來說,非對稱加密的應用場景一般有兩種,一種是公鑰加密,私鑰解密,可以應用在加解密場景中(不過由於非對稱加密的效率實在不高,用的比較少),關注工衆號:碼猿技術專欄,回覆關鍵詞:BAT,獲取大廠面試真題;還有一種就是結合摘要算法,把信息經過摘要後,再用私鑰加密,公鑰用來解密,可以應用在簽名場景中,也是我們將要使用到的方式。

大致看看RSASignature簽名的方式,稍後用到SHA256withRSA底層就是使用的這個方法。

摘要算法與非對稱算法的最大區別就在於,它是一種不需要密鑰的且不可逆的算法,也就是一旦明文數據經過摘要算法計算後,得到的密文數據一定是不可反推回來的。

簽名的作用

好了,現在我們再來看看簽名,簽名主要可以用在兩個場景,一種是數據防篡改,一種是身份防冒充,實際上剛好可以對應上前面我們介紹的兩種算法。

數據防篡改

顧名思義,就是防止數據在網絡傳輸過程中被修改,摘要算法可以保證每次經過摘要算法的原始數據,計算出來的結果都一樣,所以一般接口提供方只要用同樣的原數據經過同樣的摘要算法,然後與接口請求方生成的數據進行比較,如果一致則表示數據沒有被篡改過。

身份防冒充

這裏身份防冒充,我們就要使用另一種方式,比如SHA256withRSA,其實現原理就是先用數據進行SHA256計算,然後再使用RSA私鑰加密,對方解的時候也一樣,先用RSA公鑰解密,然後再進行SHA256計算,最後看結果是否匹配。

三、使用示例

前置準備

  1. 在沒有自動化開放平臺時,appId、appSecret可直接通過線下的方式給到接入方,appSecret需要接入方自行保存好,避免泄露。也可以自行

  2. 公私鑰可以由接口提供方來生成,同樣通過線下的方式,把私鑰交給對方,並要求對方需保密。

交互流程

客戶端準備

  1. 接口請求方,首先把業務參數,進行摘要算法計算,生成一個簽名(sign)
// 業務請求參數
UserEntity userEntity = new UserEntity();
userEntity.setUserId("1");
userEntity.setPhone("13912345678");
// 使用sha256的方式生成簽名
String sign = getSHA256Str(JSONObject.toJSONString(userEntity));
sign=c630885277f9d31cf449697238bfc6b044a78545894c83aad2ff6d0b7d486bc5
  1. 然後繼續拼接header部的參數,可以使用&符合連接,使用Set集合完成自然排序,並且過濾參數爲空的key,最後使用私鑰加簽的方式,得到appSign
Map<String, String> data = Maps.newHashMap();
data.put("appId", appId);
data.put("nonce", nonce);
data.put("sign", sign);
data.put("timestamp", timestamp);
Set<String> keySet = data.keySet();
String[] keyArray = keySet.toArray(new String[keySet.size()]);
Arrays.sort(keyArray);
StringBuilder sb = new StringBuilder();
for (String k : keyArray) {
    if (data.get(k).trim().length() > 0) // 參數值爲空,則不參與簽名
        sb.append(k).append("=").append(data.get(k).trim()).append("&");
}
sb.append("appSecret=").append(appSecret);
System.out.println("【請求方】拼接後的參數:" + sb.toString());
System.out.println();
【請求方】拼接後的參數:appId=123456&nonce=1234&sign=c630885277f9d31cf449697238bfc6b044a78545894c83aad2ff6d0b7d486bc5×tamp=1653057661381&appSecret=654321
【請求方】appSign:m/xk0fkDZlHEkbYSpCPdpbriG/EWG9gNZtInoYOu2RtrLMzHNM0iZe1iL4p/+IedAJN2jgG9pS5o5NZH1i55TVoTbZePdCbR9CEJoHq2TZLIiKPeoRgDimAl14V5jHZiMQCXS8RxWT63W8MKFyZQtB7xCtxVD7+IvLGQOAWn7QX+EmfAUvhgjkaVf2YLk9J9LqtyjfTYeloiP901ZsBZo5y9Gs5P73b+JoEcxmGZRv+Fkv3HnHWTQEpl7W6Lrmd0j44/XupwzHxaanRo5k0ALOVSFohdyMtHk3eOYx/bj+GeMKf8PN4J4tsPndnjyu4XUOnh74aaW9oC2DLiIzr4+Q==
  1. 最後把參數組裝,發送給接口提供方。
Header header = Header.builder()
        .appId(appId)
        .nonce(nonce)
        .sign(sign)
        .timestamp(timestamp)
        .appSign(appSign)
        .build();
APIRequestEntity apiRequestEntity = new APIRequestEntity();
apiRequestEntity.setHeader(header);
apiRequestEntity.setBody(userEntity);
String requestParam = JSONObject.toJSONString(apiRequestEntity);
System.out.println("【請求方】接口請求參數: " + requestParam);
【請求方】接口請求參數: {"body":{"phone":"13912345678","userId":"1"},"header":{"appId":"123456","appSign":"m/xk0fkDZlHEkbYSpCPdpbriG/EWG9gNZtInoYOu2RtrLMzHNM0iZe1iL4p/+IedAJN2jgG9pS5o5NZH1i55TVoTbZePdCbR9CEJoHq2TZLIiKPeoRgDimAl14V5jHZiMQCXS8RxWT63W8MKFyZQtB7xCtxVD7+IvLGQOAWn7QX+EmfAUvhgjkaVf2YLk9J9LqtyjfTYeloiP901ZsBZo5y9Gs5P73b+JoEcxmGZRv+Fkv3HnHWTQEpl7W6Lrmd0j44/XupwzHxaanRo5k0ALOVSFohdyMtHk3eOYx/bj+GeMKf8PN4J4tsPndnjyu4XUOnh74aaW9oC2DLiIzr4+Q==","nonce":"1234","sign":"c630885277f9d31cf449697238bfc6b044a78545894c83aad2ff6d0b7d486bc5","timestamp":"1653057661381"}}

服務端準備

  1. 從請求參數中,先獲取body的內容,然後簽名,完成對參數校驗
Header header = apiRequestEntity.getHeader();
UserEntity userEntity = JSONObject.parseObject(JSONObject.toJSONString(apiRequestEntity.getBody()), UserEntity.class);
// 首先,拿到參數後同樣進行簽名
String sign = getSHA256Str(JSONObject.toJSONString(userEntity));
if (!sign.equals(header.getSign())) {
    throw new Exception("數據簽名錯誤!");
}
  1. header中獲取相關信息,並使用公鑰進行驗籤,完成身份認證
// 從header中獲取相關信息,其中appSecret需要自己根據傳過來的appId來獲取
String appId = header.getAppId();
String appSecret = getAppSecret(appId);
String nonce = header.getNonce();
String timestamp = header.getTimestamp();
// 按照同樣的方式生成appSign,然後使用公鑰進行驗籤
Map<String, String> data = Maps.newHashMap();
data.put("appId", appId);
data.put("nonce", nonce);
data.put("sign", sign);
data.put("timestamp", timestamp);
Set<String> keySet = data.keySet();
String[] keyArray = keySet.toArray(new String[keySet.size()]);
Arrays.sort(keyArray);
StringBuilder sb = new StringBuilder();
for (String k : keyArray) {
    if (data.get(k).trim().length() > 0) // 參數值爲空,則不參與簽名
        sb.append(k).append("=").append(data.get(k).trim()).append("&");
}
sb.append("appSecret=").append(appSecret);
if (!rsaVerifySignature(sb.toString(), appKeyPair.get(appId).get("publicKey"), header.getAppSign())) {
    throw new Exception("公鑰驗籤錯誤!");
}
System.out.println();
System.out.println("【提供方】驗證通過!");

完整代碼示例

package openApi;
import com.alibaba.fastjson.JSONObject;
import com.google.common.collect.Maps;
import lombok.SneakyThrows;
import org.apache.commons.codec.binary.Hex;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.*;
public class AppUtils {
    /**
     * key:appId、value:appSecret
     */
    static Map<String, String> appMap = Maps.newConcurrentMap();
    /**
     * 分別保存生成的公私鑰對
     * key:appId,value:公私鑰對
     */
    static Map<String, Map<String, String>> appKeyPair = Maps.newConcurrentMap();
    public static void main(String[] args) throws Exception {
        // 模擬生成appId、appSecret
        String appId = initAppInfo();
        // 根據appId生成公私鑰對
        initKeyPair(appId);
        // 模擬請求方
        String requestParam = clientCall();
        // 模擬提供方驗證
        serverVerify(requestParam);
    }
    private static String initAppInfo() {
        // appId、appSecret生成規則,依據之前介紹過的方式,保證全局唯一即可
        String appId = "123456";
        String appSecret = "654321";
        appMap.put(appId, appSecret);
        return appId;
    }
    private static void serverVerify(String requestParam) throws Exception {
        APIRequestEntity apiRequestEntity = JSONObject.parseObject(requestParam, APIRequestEntity.class);
        Header header = apiRequestEntity.getHeader();
        UserEntity userEntity = JSONObject.parseObject(JSONObject.toJSONString(apiRequestEntity.getBody()), UserEntity.class);
        // 首先,拿到參數後同樣進行簽名
        String sign = getSHA256Str(JSONObject.toJSONString(userEntity));
        if (!sign.equals(header.getSign())) {
            throw new Exception("數據簽名錯誤!");
        }
        // 從header中獲取相關信息,其中appSecret需要自己根據傳過來的appId來獲取
        String appId = header.getAppId();
        String appSecret = getAppSecret(appId);
        String nonce = header.getNonce();
        String timestamp = header.getTimestamp();
        // 按照同樣的方式生成appSign,然後使用公鑰進行驗籤
        Map<String, String> data = Maps.newHashMap();
        data.put("appId", appId);
        data.put("nonce", nonce);
        data.put("sign", sign);
        data.put("timestamp", timestamp);
        Set<String> keySet = data.keySet();
        String[] keyArray = keySet.toArray(new String[keySet.size()]);
        Arrays.sort(keyArray);
        StringBuilder sb = new StringBuilder();
        for (String k : keyArray) {
            if (data.get(k).trim().length() > 0) // 參數值爲空,則不參與簽名
                sb.append(k).append("=").append(data.get(k).trim()).append("&");
        }
        sb.append("appSecret=").append(appSecret);
        if (!rsaVerifySignature(sb.toString(), appKeyPair.get(appId).get("publicKey"), header.getAppSign())) {
            throw new Exception("公鑰驗籤錯誤!");
        }
        System.out.println();
        System.out.println("【提供方】驗證通過!");
    }
    public static String clientCall() {
        // 假設接口請求方與接口提供方,已經通過其他渠道,確認了雙方交互的appId、appSecret
        String appId = "123456";
        String appSecret = "654321";
        String timestamp = String.valueOf(System.currentTimeMillis());
        // 應該爲隨機數,演示隨便寫一個
        String nonce = "1234";
        // 業務請求參數
        UserEntity userEntity = new UserEntity();
        userEntity.setUserId("1");
        userEntity.setPhone("13912345678");
        // 使用sha256的方式生成簽名
        String sign = getSHA256Str(JSONObject.toJSONString(userEntity));
        Map<String, String> data = Maps.newHashMap();
        data.put("appId", appId);
        data.put("nonce", nonce);
        data.put("sign", sign);
        data.put("timestamp", timestamp);
        Set<String> keySet = data.keySet();
        String[] keyArray = keySet.toArray(new String[keySet.size()]);
        Arrays.sort(keyArray);
        StringBuilder sb = new StringBuilder();
        for (String k : keyArray) {
            if (data.get(k).trim().length() > 0) // 參數值爲空,則不參與簽名
                sb.append(k).append("=").append(data.get(k).trim()).append("&");
        }
        sb.append("appSecret=").append(appSecret);
        System.out.println("【請求方】拼接後的參數:" + sb.toString());
        System.out.println();
        // 使用sha256withRSA的方式對header中的內容加簽
        String appSign = sha256withRSASignature(appKeyPair.get(appId).get("privateKey"), sb.toString());
        System.out.println("【請求方】appSign:" + appSign);
        System.out.println();
        // 請求參數組裝
        Header header = Header.builder()
                .appId(appId)
                .nonce(nonce)
                .sign(sign)
                .timestamp(timestamp)
                .appSign(appSign)
                .build();
        APIRequestEntity apiRequestEntity = new APIRequestEntity();
        apiRequestEntity.setHeader(header);
        apiRequestEntity.setBody(userEntity);
        String requestParam = JSONObject.toJSONString(apiRequestEntity);
        System.out.println("【請求方】接口請求參數: " + requestParam);
        return requestParam;
    }
    /**
     * 私鑰簽名
     *
     * @param privateKeyStr
     * @param dataStr
     * @return
     */
    public static String sha256withRSASignature(String privateKeyStr, String dataStr) {
        try {
            byte[] key = Base64.getDecoder().decode(privateKeyStr);
            byte[] data = dataStr.getBytes();
            PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(key);
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            PrivateKey privateKey = keyFactory.generatePrivate(keySpec);
            Signature signature = Signature.getInstance("SHA256withRSA");
            signature.initSign(privateKey);
            signature.update(data);
            return new String(Base64.getEncoder().encode(signature.sign()));
        } catch (Exception e) {
            throw new RuntimeException("簽名計算出現異常", e);
        }
    }
    /**
     * 公鑰驗籤
     *
     * @param dataStr
     * @param publicKeyStr
     * @param signStr
     * @return
     * @throws Exception
     */
    public static boolean rsaVerifySignature(String dataStr, String publicKeyStr, String signStr) throws Exception {
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        X509EncodedKeySpec x509EncodedKeySpec = new X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyStr));
        PublicKey publicKey = keyFactory.generatePublic(x509EncodedKeySpec);
        Signature signature = Signature.getInstance("SHA256withRSA");
        signature.initVerify(publicKey);
        signature.update(dataStr.getBytes());
        return signature.verify(Base64.getDecoder().decode(signStr));
    }
    /**
     * 生成公私鑰對
     *
     * @throws Exception
     */
    public static void initKeyPair(String appId) throws Exception {
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        keyPairGenerator.initialize(2048);
        KeyPair keyPair = keyPairGenerator.generateKeyPair();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        Map<String, String> keyMap = Maps.newHashMap();
        keyMap.put("publicKey", new String(Base64.getEncoder().encode(publicKey.getEncoded())));
        keyMap.put("privateKey", new String(Base64.getEncoder().encode(privateKey.getEncoded())));
        appKeyPair.put(appId, keyMap);
    }
    private static String getAppSecret(String appId) {
        return String.valueOf(appMap.get(appId));
    }
    @SneakyThrows
    public static String getSHA256Str(String str) {
        MessageDigest messageDigest;
        messageDigest = MessageDigest.getInstance("SHA-256");
        byte[] hash = messageDigest.digest(str.getBytes(StandardCharsets.UTF_8));
        return Hex.encodeHexString(hash);
    }
}

四、常見防護手段

timestamp

前面在接口設計中,我們使用到了timestamp,這個參數主要可以用來防止同一個請求參數被無限期的使用。

稍微修改一下原服務端校驗邏輯,增加了 5 分鐘有效期的校驗邏輯。

private static void serverVerify(String requestParam) throws Exception {
    APIRequestEntity apiRequestEntity = JSONObject.parseObject(requestParam, APIRequestEntity.class);
    Header header = apiRequestEntity.getHeader();
    UserEntity userEntity = JSONObject.parseObject(JSONObject.toJSONString(apiRequestEntity.getBody()), UserEntity.class);
    // 首先,拿到參數後同樣進行簽名
    String sign = getSHA256Str(JSONObject.toJSONString(userEntity));
    if (!sign.equals(header.getSign())) {
        throw new Exception("數據簽名錯誤!");
    }
    // 從header中獲取相關信息,其中appSecret需要自己根據傳過來的appId來獲取
    String appId = header.getAppId();
    String appSecret = getAppSecret(appId);
    String nonce = header.getNonce();
    String timestamp = header.getTimestamp();
    // 請求時間有效期校驗
    long now = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
    if ((now - Long.parseLong(timestamp)) / 1000 / 60 >= 5) {
        throw new Exception("請求過期!");
    }
    cache.put(appId + "_" + nonce, "1");
    // 按照同樣的方式生成appSign,然後使用公鑰進行驗籤
    Map<String, String> data = Maps.newHashMap();
    data.put("appId", appId);
    data.put("nonce", nonce);
    data.put("sign", sign);
    data.put("timestamp", timestamp);
    Set<String> keySet = data.keySet();
    String[] keyArray = keySet.toArray(new String[0]);
    Arrays.sort(keyArray);
    StringBuilder sb = new StringBuilder();
    for (String k : keyArray) {
        if (data.get(k).trim().length() > 0) // 參數值爲空,則不參與簽名
            sb.append(k).append("=").append(data.get(k).trim()).append("&");
    }
    sb.append("appSecret=").append(appSecret);
    if (!rsaVerifySignature(sb.toString(), appKeyPair.get(appId).get("publicKey"), header.getAppSign())) {
        throw new Exception("驗籤錯誤!");
    }
    System.out.println();
    System.out.println("【提供方】驗證通過!");
}

nonce

nonce值是一個由接口請求方生成的隨機數,在有需要的場景中,可以用它來實現請求一次性有效,也就是說同樣的請求參數只能使用一次,這樣可以避免接口重放攻擊。

具體實現方式:接口請求方每次請求都會隨機生成一個不重複的nonce值,接口提供方可以使用一個存儲容器(爲了方便演示,我使用的是guava提供的本地緩存,生產環境中可以使用redis這樣的分佈式存儲方式),每次先在容器中看看是否存在接口請求方發來的nonce值,如果不存在則表明是第一次請求,則放行,並且把當前nonce值保存到容器中,這樣,如果下次再使用同樣的nonce來請求則容器中一定存在,那麼就可以判定是無效請求了。

這裏可以設置緩存的失效時間爲 5 分鐘,因爲前面有效期已經做了 5 分鐘的控制。

static Cache<String, String> cache = CacheBuilder.newBuilder()
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .build();
private static void serverVerify(String requestParam) throws Exception {
    APIRequestEntity apiRequestEntity = JSONObject.parseObject(requestParam, APIRequestEntity.class);
    Header header = apiRequestEntity.getHeader();
    UserEntity userEntity = JSONObject.parseObject(JSONObject.toJSONString(apiRequestEntity.getBody()), UserEntity.class);
    // 首先,拿到參數後同樣進行簽名
    String sign = getSHA256Str(JSONObject.toJSONString(userEntity));
    if (!sign.equals(header.getSign())) {
        throw new Exception("數據簽名錯誤!");
    }
    // 從header中獲取相關信息,其中appSecret需要自己根據傳過來的appId來獲取
    String appId = header.getAppId();
    String appSecret = getAppSecret(appId);
    String nonce = header.getNonce();
    String timestamp = header.getTimestamp();
    // 請求時間有效期校驗
    long now = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
    if ((now - Long.parseLong(timestamp)) / 1000 / 60 >= 5) {
        throw new Exception("請求過期!");
    }
    // nonce有效性判斷
    String str = cache.getIfPresent(appId + "_" + nonce);
    if (Objects.nonNull(str)) {
        throw new Exception("請求失效!");
    }
    cache.put(appId + "_" + nonce, "1");
    // 按照同樣的方式生成appSign,然後使用公鑰進行驗籤
    Map<String, String> data = Maps.newHashMap();
    data.put("appId", appId);
    data.put("nonce", nonce);
    data.put("sign", sign);
    data.put("timestamp", timestamp);
    Set<String> keySet = data.keySet();
    String[] keyArray = keySet.toArray(new String[0]);
    Arrays.sort(keyArray);
    StringBuilder sb = new StringBuilder();
    for (String k : keyArray) {
        if (data.get(k).trim().length() > 0) // 參數值爲空,則不參與簽名
            sb.append(k).append("=").append(data.get(k).trim()).append("&");
    }
    sb.append("appSecret=").append(appSecret);
    if (!rsaVerifySignature(sb.toString(), appKeyPair.get(appId).get("publicKey"), header.getAppSign())) {
        throw new Exception("驗籤錯誤!");
    }
    System.out.println();
    System.out.println("【提供方】驗證通過!");
}

訪問權限

數據訪問權限,一般可根據 appId 的身份來獲取開放給其的相應權限,要確保每個 appId 只能訪問其權限範圍內的數據。

參數合法性校驗

參數的合法性校驗應該是每個接口必備的,無論是前端發起的請求,還是後端的其他調用都必須對參數做校驗,比如:參數的長度、類型、格式,必傳參數是否有傳,是否符合約定的業務規則等等。

推薦使用SpringBoot Validation來快速實現一些基本的參數校驗。

參考如下示例:

@Data
@ToString
public class DemoEntity {
 // 不能爲空,比較時會除去空格
    @NotBlank(message = "名稱不能爲空")
    private String name;
 // amount必須是一個大於等於5,小於等於10的數字
    @DecimalMax(value = "10")
    @DecimalMin(value = "5")
    private BigDecimal amount;
 // 必須符合email格式
    @Email
    private String email;
 // size長度必須在5到10之間
    @Size(max = 10, min = 5)
    private String size;
 // age大小必須在18到35之間
    @Min(value = 18)
    @Max(value = 35)
    private int age;
 // user不能爲null
    @NotNull
    private User user;
 // 限制必須爲小數,且整數位integer最多2位,小數位fraction最多爲4位
    @Digits(integer = 2, fraction = 4)
    private BigDecimal digits;
 // 限制必須爲未來的日期
    @Future
    private Date future;
 // 限制必須爲過期的日期
    @Past
    private Date past;
 // 限制必須是一個未來或現在的時間
    @FutureOrPresent
    private Date futureOrPast;
 // 支持正則表達式
 @Pattern(regexp = "^\\d+$")
 private String digit;
}
@RestController
@Slf4j
@RequestMapping("/valid")
public class TestValidController {
    @RequestMapping("/demo1")
    public String demo12(@Validated @RequestBody DemoEntity demoEntity) {
        try {
            return "SUCCESS";
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            return "FAIL";
        }
    }
}

限流保護

在設計接口時,我們應當對接口的負載能力做出評估,尤其是開放給外部使用時,這樣當實際請求流量超過預期流量時,我們便可採取相應的預防策略,以免服務器崩潰。

一般來說限流主要是爲了防止惡意刷站請求,爬蟲等非正常的業務訪問,因此一般來說採取的方式都是直接丟棄超出閾值的部分。

限流的具體實現有多種,單機版可以使用Guava的RateLimiter,分佈式可以使用Redis,想要更加完善的成套解決方案則可以使用阿里開源的Sentinel

敏感數據訪問

敏感信息一般包含,身份證、手機號、銀行卡號、車牌號、姓名等等,應該按照脫敏規則進行處理。

白名單機制

使用白名單機制可以進一步加強接口的安全性,一旦服務與服務交互可以使用,接口提供方可以限制只有白名單內的 IP 才能訪問,這樣接口請求方只要把其出口 IP 提供出來即可。

黑名單機制

與之對應的黑名單機制,則是應用在服務端與客戶端的交互,由於客戶端 IP 都是不固定的,所以無法使用白名單機制,不過我們依然可以使用黑名單攔截一些已經被識別爲非法請求的 IP。

五、其他考慮

  1. 名稱和描述:API 的名稱和描述應該簡潔明瞭,並清晰地表明其功能和用途。

  2. 請求和響應:API 應該支持標準的 HTTP 請求方法,如 GET、POST、PUT 和 DELETE,並定義這些方法的參數和響應格式。

  3. 錯誤處理:API 應該定義各種錯誤碼,並提供有關錯誤的詳細信息。

  4. 文檔和示例:API 應該提供文檔和示例,以幫助開發人員瞭解如何使用該 API,並提供示例數據以進行測試。

  5. 可擴展:API 應當考慮未來的升級擴展不但能夠向下兼容(一般可以在接口參數中添加接口的版本號),還能方便添加新的能力。

六、額外補充

1. 關於 MD5 應用的介紹

在提到對於開放接口的安全設計時,一定少不了對於摘要算法的應用(MD5 算法是其實現方式之一),在接口設計方面它可以幫助我們完成數據簽名的功能,也就是說用來防止請求或者返回的數據被他人篡改。

本節我們單從安全的角度出發,看看到底哪些場景下的需求可以藉助 MD5 的方式來實現。

密碼存儲

在一開始的時候,大多數服務端對於用戶密碼的存儲肯定都是明文的,這就導致了一旦存儲密碼的地方被發現,無論是黑客還是服務端維護人員自己,都可以輕鬆的得到用戶的賬號、密碼,並且其實很多用戶的賬號、密碼在各種網站上都是一樣的,也就是說一旦因爲有一家網站數據保護的不好,導致信息被泄露,那可能對於用戶來說影響的則是他的所有賬號密碼的地方都被泄露了,想想看這是多少可怕的事情。

所以,那應該要如何存儲用戶的密碼呢?最安全的做法當然就是不存儲,這聽起來很奇怪,不存儲密碼那又如何能夠校驗密碼,實際上不存儲指的是不存儲用戶直接輸入的密碼。

如果用戶直接輸入的密碼不存儲,那應該存儲什麼呢?到這裏,MD5 就派上用場了,經過 MD5 計算後的數據有這麼幾個特點:

  1. 其長度是固定的。

  2. 其數據是不可逆的。

  3. 一份原始數據每次 MD5 後產生的數據都是一樣的。

下面我們來實驗一下

public static void main(String[] args) {
    String pwd = "123456";
    String s = DigestUtils.md5Hex(pwd);
    System.out.println("第一次MD5計算:" + s);
    String s1 = DigestUtils.md5Hex(pwd);
    System.out.println("第二次MD5計算:" + s1);
    pwd = "123456789";
    String s3 = DigestUtils.md5Hex(pwd);
    System.out.println("原數據長度變長,經過MD5計算後長度固定:" + s3);
}
第一次MD5計算:e10adc3949ba59abbe56e057f20f883e
第二次MD5計算:e10adc3949ba59abbe56e057f20f883e
原數據長度變長,經過MD5計算後長度固定:25f9e794323b453885f5181f1b624d0b

有了這樣的特性後,我們就可以用它來存儲用戶的密碼了。

  public static Map<String, String> pwdMap = Maps.newConcurrentMap();
    public static void main(String[] args) {
        // 一般情況下,用戶在前端輸入密碼後,向後臺傳輸時,就已經是經過MD5計算後的數據了,所以後臺只需要直接保存即可
        register("1", DigestUtils.md5Hex("123456"));
        // true
        System.out.println(verifyPwd("1", DigestUtils.md5Hex("123456")));
        // false
        System.out.println(verifyPwd("1", DigestUtils.md5Hex("1234567")));
    }
    // 用戶輸入的密碼,在前端已經經過MD5計算了,所以到時候校驗時直接比對即可
    public static boolean verifyPwd(String account, String pwd) {
        String md5Pwd = pwdMap.get(account);
        return Objects.equals(md5Pwd, pwd);
    }
    public static void register(String account, String pwd) {
        pwdMap.put(account, pwd);
    }

MD5 後就安全了嗎?

目前爲止,雖然我們已經對原始數據進行了 MD5 計算,並且也得到了一串唯一且不可逆的密文,但實際上還遠遠不夠,關注工衆號:碼猿技術專欄,回覆關鍵詞:111,獲取阿里內部 Java 性能調優手冊!;不信,我們找一個破解 MD5 的網站試一下!

我們把前面經過 MD5 計算後得到的密文查詢一下試試,結果居然被查詢出來了!

之所以會這樣,其實恰好就是利用了 MD5 的特性之一:一份原始數據每次 MD5 後產生的數據都是一樣的。

試想一想,雖然我們不能通過密文反解出明文來,但是我們可以直接用明文去和猜,假設有人已經把所有可能出現的明文組合,都經過 MD5 計算後,並且保存了起來,那當拿到密文後,只需要去記錄庫裏匹配一下密文就能得到明文了,正如上圖這個網站的做法一樣。

對於保存這樣數據的表,還有個專門的名詞:彩虹表,也就是說只要時間足夠、空間足夠,也一定能夠破解出來。

加鹽

正因爲上述情況的存在,所以出現了加鹽的玩法,說白了就是在原始數據中,再摻雜一些別的數據,這樣就不會那麼容易破解了。

String pwd = "123456";
String salt = "wylsalt";
String s = DigestUtils.md5Hex(salt + pwd);
System.out.println("第一次MD5計算:" + s);
第一次MD5計算:b9ff58406209d6c4f97e1a0d424a59ba
你看,簡單加一點內容,破解網站就查詢不到了吧!

攻防都是在不斷的博弈中進行升級,很遺憾,如果僅僅做成這樣,實際上還是不夠安全,比如攻擊者自己註冊一個賬號,密碼就設置成1

String pwd = "1";
String salt = "wylsalt";
String s = DigestUtils.md5Hex(salt + pwd);
System.out.println("第一次MD5計算:" + s);
第一次MD5計算:4e7b25db2a0e933b27257f65b117582a

雖然要付費,但是明顯已經是匹配到結果了。

所以說,無論是密碼還是鹽值,其實都要求其本身要保證有足夠的長度和複雜度,這樣才能防止像彩虹表這樣被存儲下來,如果再能定期更換一個,那就更安全了,雖說無論再複雜,理論上都可以被窮舉到,但越長的數據,想要被窮舉出來的時間則也就越長,所以相對來說也就是安全的。

數字簽名

摘要算法另一個常見的應用場景就是數字簽名了,前面章節也有介紹過了

大致流程,百度百科也有介紹

2. 對稱加密算法

對稱加密算法是指通過密鑰對原始數據(明文),進行特殊的處理後,使其變成密文發送出去,數據接收方收到數據後,再使用同樣的密鑰進行特殊處理後,再使其還原爲原始數據(明文),對稱加密算法中密鑰只有一個,數據加密與解密方都必須事先約定好。

對稱加密算法特點

  1. 只有一個密鑰,加密和解密都使用它。

  2. 加密、解密速度快、效率高。

  3. 由於數據加密方和數據解密方使用的是同一個密鑰,因此密鑰更容易被泄露。

常用的加密算法介紹

DES

其入口參數有三個:key、data、mode。key 爲加密解密使用的密鑰,data 爲加密解密的數據,mode 爲其工作模式。當模式爲加密模式時,明文按照 64 位進行分組,形成明文組,key 用於對數據加密,當模式爲解密模式時,key 用於對數據解密。實際運用中,密鑰只用到了 64 位中的 56 位,這樣才具有高的安全性。

算法特點

DES 算法具有極高安全性,除了用窮舉搜索法對 DES 算法進行攻擊外,還沒有發現更有效的辦法。而 56 位長的密鑰的窮舉空間爲 2^56,這意味着如果一臺計算機的速度是每一秒鐘檢測一百萬個密鑰,則它搜索完全部密鑰就需要將近 2285 年的時間,可見,這是難以實現的。然而,這並不等於說 DES 是不可破解的。而實際上,隨着硬件技術和 Internet 的發展,其破解的可能性越來越大,而且,所需要的時間越來越少。使用經過特殊設計的硬件並行處理要幾個小時。

爲了克服 DES 密鑰空間小的缺陷,人們又提出了 3DES 的變形方式。

3DES

3DES 相當於對每個數據塊進行三次 DES 加密算法,雖然解決了 DES 不夠安全的問題,但效率上也相對慢了許多。

AES

AES 用來替代原先的 DES 算法,是當前對稱加密中最流行的算法之一。

ECB 模式

AES 加密算法中一個重要的機制就是分組加密,而 ECB 模式就是最簡單的一種分組加密模式,比如按照每 128 位數據塊大小將數據分成若干塊,之後再對每一塊數據使用相同的密鑰進行加密,最終生成若干塊加密後的數據,這種算法由於每個數據塊可以進行獨立的加密、解密,因此可以進行並行計算,效率很高,但也因如此,則會很容易被猜測到密文的規律。

private static final String AES_ALG = "AES";
private static final String AES_ECB_PCK_ALG = "AES/ECB/NoPadding";
public static void main(String[] args) throws Exception {
    System.out.println("第一次加密:" + encryptWithECB("1234567812345678", "50AHsYx7H3OHVMdF123456", "UTF-8"));
 System.out.println("第二次加密:" + encryptWithECB("12345678123456781234567812345678", "50AHsYx7H3OHVMdF123456", "UTF-8"));
}
public static String encryptWithECB(String content, String aesKey, String charset) throws Exception {
    Cipher cipher = Cipher.getInstance(AES_ECB_PCK_ALG);
    cipher.init(Cipher.ENCRYPT_MODE,
            new SecretKeySpec(Base64.decodeBase64(aesKey.getBytes()), AES_ALG));
    byte[] encryptBytes = cipher.doFinal(content.getBytes(charset));
    return Hex.encodeHexString(encryptBytes);
}
第一次加密:87d2d15dbcb5747ed16cfe4c029e137c
第二次加密:87d2d15dbcb5747ed16cfe4c029e137c87d2d15dbcb5747ed16cfe4c029e137c
可以看出,加密後的密文明顯也是重複的,因此針對這一特性可進行分組重放攻擊。

CBC 模式

CBC 模式引入了初始化向量的概念(IV),第一組分組會使用向量值與第一塊明文進行異或運算,之後得到的結果既是密文塊,也是與第二塊明文進行異或的對象,以此類推,最終解決了 ECB 模式的安全問題。

CBC 模式的特點

CBC 模式安全性比 ECB 模式要高,但由於每一塊數據之間有依賴性,所以無法進行並行計算,效率沒有 ECB 模式高。

作者:碼拉松

來源:https://juejin.cn/post/7268203669592784936

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/IyRi9IHT6vvxPwKDD-Pb2g