淺談API的設計及其安全性

淺談API的設計及其安全性

看起來好像前後端分離是個浪潮,原來只有APP客戶端會考慮這些,現在連Web都要考慮前後端分離 。
這裏面不得不談的就是API的設計和安全性,這些個問題不解決好,將會給服務器安全和性能帶來很大威脅 。
下面我也是根據自己的一些經歷和經驗說下自己的一些心得 。
API的設計中,主要考慮兩大方面的問題 :

  • 防止API被惡意調用
  • API通信中數據加密的問題

由於HTTP協議是無狀態的,所以在做MVC Web的時候,無論是Java Web還是PHP等,大多數都是依靠session/cookie來完成的用戶標識的(如果你不瞭解session相關內容,請自行搜索)。但在前後端分離的開發模式中,session/cookie模式就顯得不太合適,尤其是APP客戶端,是不太可能用session/cookie的,業界廣泛採用的方式就是採用token。

我們用樓、樓管和租戶來做個類比。我們的系統是一棟樓,一名管理員負責管理這棟樓和租戶,有100個租戶,每個租戶都被分配了屬於自己的一個房間而且租戶不可以隨意進入其他租戶的房間。當租戶第一次來登記的時候,樓管就會要求租戶出具身份證(用戶名和密碼),在覈驗完畢身份證後(驗證密碼)後,管理員會把一件房的鑰匙(token)給租戶並同時自己也記錄下鑰匙和房間號。自此之後,用戶每次進出自己房間只需要用自己的鑰匙即可。但後來樓管覺得用戶長期持有同一把鑰匙有些不安全,比如鑰匙被別人克隆了一把又或者鑰匙丟了讓別人撿了,這樣會比較危險,所以樓管又決定給每把分配出去的鑰匙有效時間,比如30天。當鑰匙到期後,就不可以再打開門鎖,必須只能再找樓管換一把新鑰匙。

回到正文中,我們引入兩個API來說明具體開發中流程。現在有兩個API:

TIPS :

  • 下面流程僅僅是大體流程,不處理具體細節。方案都是爲了說明問題才用簡單粗暴,可以根據大概原理自己加入更豐富的元素
  • 僞代碼中出現的post不表示使用POST方式提交數據,僅僅表示人們口中常說的PO數據
  • 本文僅爲API設計提出大體方案,比如簽名機制,但具體簽名機制採取多少數據項才、用md5或者sha1可自主決定

客戶端需要根據服務端API文檔首先實現登錄頁面,假如文檔要求以POST方式json協議提交數據,僞代碼演示如下:


 
  1. http.post( 'http://host.com/api/account/login', { "account":"zhangsan", "password":"123456" } )

服務端收到數據後,從數據庫中驗證用戶名和密碼(檢查租戶身份證),如果錯誤,返回錯誤提示,如果正確,就要生成一個token(頒發鑰匙)給用戶並同時自己也要記錄下token是代表了哪位用戶(記錄下鑰匙給了哪個用戶)。假如用戶的uid是8,生成的token是abcdefg,那麼也就是說abcdefg這個token分配給了8號用戶(8號租戶持有鑰匙abcdefg),客戶端自己需要保存住這個token(租戶自己持有鑰匙)。

當用戶需要訪問自己訂單的時候,也就是需要訪問API http://host.com/api/order/list 的時候,就要帶上token,因爲服務端是記錄了token和用戶uid對應關係的,所以服務器就可以根據token得知當前訪問的用戶是誰並返回給該用戶其訂單內容,僞代碼演示如下:


 
  1. http.post( 'http://host.com/api/order/list', { "token" : "abcdefg" } );

這樣就基本上已經實現了客戶端和服務端通信了,但實際上僅僅這樣還是有很大的安全風險。如果任何一個人通過抓包等方式得知了服務器API的地址,就意味着他可以任意調用API了,我們的API會被盜用。爲了避免這種被盜用,引入簽名機制。也就說訪問任何一個API的時候,都需要驗證簽名,只有簽名通過了纔可以繼續下去,否則就會彈出錯誤信息。僞代碼演示如下:


 
  1. // 這裏可以看到簽名的機制就是將api使用md5 hash一下
  2. // 訪問帳號登錄API
  3. http.post( 'http://host.com/api/account/login', { "account":"zhangsan", "password":"123456" } ).signature( 'api/account/login' )
  4. // 訪問我的訂單API
  5. http.post( 'http://host.com/api/order/list', { "token" : "abcdefg" } ).signature( 'api/order/list' )

服務端收到數據後,也用相同的簽名方式運算出簽名與客戶端傳遞來的簽名進行對比,僞代碼演示如下:


 
  1. server_signature = md5( 'api/account/login' )
  2. client_signature = http.get_post( 'signature' )
  3. if ( server_signature != client_signature ) {
  4. return 'signature error';
  5. }

在具備這種簽名機制後,如果客戶端被反編譯了,簽名機制就會被人得知。所以,在簽名機制中引入另外一個新的重要元素:時間戳。時間戳的引入有兩個重要作用:

  • 判定某次API訪問的時效性
  • 參與簽名運算

假如某次訪問 http://host.com/api/order/list 的時候timestamp值爲123456789,簽名爲”xyz”,有惡意用戶記錄下該所有的數據然後反覆調用。如果我們在服務端對比服務器時間和用戶提交過來的時間戳,兩者相差巨大超出一天或者半個小時,那麼就可以直接返回一些諸如“過期的API訪問”等等錯誤提示。

事情做到這裏看起來已經基本比較完善了,這樣的簽名制度看起來能夠抵擋相當大一部分惡意調用了。實際上,在真正完善的API設計中,API都會由API網關來實現,API網關中有一項功能就是防刷限流,可以根據不同維度比如用戶、IP地址、設備ID來限制其每秒鐘內對某個API的最多訪問次數。

截止到目前爲止,對於敏感數據包括token在內,我們都是明文傳輸的。我們需要對敏感數據加密,假如此時產品經理提出了第三個要求:添加銀行卡,銀行卡號算是敏感信息吧。

至於數據保密性的問題,我們第一點想起的自然是https了。但是,https在面對charles等抓包工具時,其實並沒有什麼卵用,只要配置一下根證書瞬間可以看到一切明文,所以,除了必要的https外,我們還需要額外的加密機制。

假如這個API是http://host.com/api/bankcard/create ,那麼加密的要求就當用戶添加銀行卡時如果數據被攔截後至少不能赤裸裸地將銀行卡號暴露出去。我們需要引入一套加密方案,對敏感數據實現加密。

加密方案不在本文討論範圍,所以我就直接選擇AES高級加密方式,AES對內容進行需要一個加密密碼,僞代碼演示一下:


 
  1. // 加密密碼
  2. password = '123456'
  3. // 需要加密的內容
  4. message = 'Hello World!'
  5. // 利用加密密碼對內容進行加密
  6. enc_message = encrypt( password, message )
  7. // 解密
  8. dec_message = decrypt ( password, enc_message )
  9. print dec_message // Hello World!

下面我們將引入加密機制後業務邏輯流程完整走一遍,估計有些同學可能已經暈頭了:


 
  1. // 第一步,客戶端執行登錄
  2. http.post( 'http://host.com/api/account/login', { "account":"zhengsan", "password":"123456" } ).signature( "api/account/login"+"timestamp" )

 
  1. // 第二步,服務端收到登錄需要,再對比完簽名和timestamp時間有效性後,執行登錄業務邏輯server_timestamp = get_timestamp()
  2. client_timestamp = get_post( 'timestamp' )
  3. if( server_timestamp - cilent_timestamp > 30 ){
  4. return '過期API訪問'
  5. }
  6. server_signature = signature( 'api/account/login' + client_timestamp )
  7. client_signature = get_post( 'signature' )
  8. if( server_signature != client_signature ){
  9. return '簽名錯誤'
  10. }
  11. // 驗證密碼並返回token
  12. password = get_post( 'password' )
  13. account = get_post( 'account' )
  14. server_password = get_password_by_account( 'account' )
  15. if( password == server_password ){
  16. // 生成一個AES加密密碼
  17. enc_password = "1a2b3c4d5f6g7h8i9j0k"
  18. // 生成原始的token
  19. token = "0k9j8h7i6g5f4c3b2c1az9y8x7"
  20. // 服務端記錄token與uid對應關係
  21. set( token, uid )
  22. // 最後一步很重要,要將aes加密密碼 和 token返回給客戶端
  23. return enc_password,token
  24. }

 
  1. // 第三步,客戶端收到登錄後的數據:加解密密碼 和 token,然後保存起來
  2. token = get( 'token' )
  3. enc_password = get('enc_password')
  4. // 將這兩項保存起來
  5. save( token, enc_password )
  6. // 先對銀行卡號進行加密,然後再進行提交
  7. bank_card = '666777888999'
  8. enc_bank_card = encrypt( enc_password, bank_card )
  9. http.post( "http://host.com/api/bankcard/create", { enc_bank_card, enc_password, token } ).signature( 'api/bankcard/create'+timestamp )

 
  1. // 第四步,服務端收到數據後,驗證API簽名和timestamp時效性,最後解密數據,入庫
  2. // 驗證signature和timestamp時效性僞代碼略過...
  3. // 獲取客戶端傳來的token enc_bankcard enc_password
  4. token = get_post( 'token' )
  5. enc_bankcard = get( 'enc_bankcard' )
  6. enc_password = get( 'enc_password' )
  7. bankcard = decrypt( enc_password, enc_bankcard )
  8. // 根據對應關係,用token找到uid
  9. uid = get_uid_by_token( 'token' )
  10. // 將uid和bankcard入庫
  11. save( uid, bankcard )

總結:

  • 簽名機制是爲了防止API被惡意調用,包括API
  • 加密是爲了保證敏感數據,敏感數據可以包括token
  • token本身與加密無關,只是token本身的含義總是跟加密似乎帶點兒關係,但實際上token僅僅是個用戶身份識別器
  • 只要客戶端被反編譯了,加密方式和簽名機制都會暴露出來,所以安全是需要雙方配合的

FAQ:

  • token和uid對應關係如何實現?
    通過redis處理,你可以考慮一個hash類型數據結構,key就用token,hash中保存完整的用戶信息

  • token或者aes加解密密碼如何傳遞?
    最好不用GET方式,建議走POST方式,也可以將這些信息放到http header中,也可以放到http body中。我自己一般習慣將signature、token、timestamp、enc_password這些信息放在http header中,API參數放在http body中

  • 我怎麼感覺enc_password這樣明文傳好危險
    其實,你可以不用完整的enc_password,你可以和客戶端協商制定一個規則,比如去掉enc_password的前三位和後兩位,用剩下的做加解密密碼

  • token本身需要加密嗎?有沒有必要所有api提交的參數都加密?
    可以加密,你甚至用解密後的token參與簽名運算,製作出更復雜的簽名規則。至於提交參數是否都加密,實際上是可以的。如果任何api參數都加密,抓包者是無法通過抓包分析你api接受參數的名稱的,比如原來是明文提交{“account”:”zhangsan”},如果該api加密提交,那麼這個json被加密成”abcdekkadadfad==”之流,抓包者由於無法得知確切的參數名稱account就無法很容易寫出一些腳本

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章