APP後端開發雜談

刀斧手何在發表於2018-02-22

Header頭

header 頭推薦加上欄位:

  • Authorization

用來存放access_token,通過該token對使用者進行認證。可以理解其作用等同於cookie中的session_id,有該令牌就預設使用者已經認證登陸過。

  • Signture 客戶端簽名

用來判斷該請求是否是客戶端APP發起的請求,現在最常見的兩種方法 一種 是通過ASE加密解密來實現。一種是通過比對資訊摘要的方式。 最簡單的生成方式是給出一個隨機值和時間戳,還有和服務端約定好的鹽值按照一定的順序進行MD5加密。

signture = md5(nonce + timastamp + salt)
複製程式碼

服務端收到該簽名的時候,按照約定的順序進行MD5加密比對簽名,如果服務端生成的簽名與客戶端簽名不一致,則可認為不是客戶端發起的請求,此時不響應該請求。當然使用這種方法要同時提交timestamp 和 隨機字串nonce。

  • version 客戶端版本

用來存放客戶端版本號,主要是小版本,大版本的話一般服務端會開新介面。但是比較好的做法,是客戶端每一次釋出都要有一個版本號。就算是此次釋出,服務端沒做任何更改,只要客戶端新發布也應該給一個新的版本號。然後將這個版本號寫入header 頭中。這樣的好處是如果請求出現錯誤,我們能夠定位到是哪個版本的APP出現了該錯誤,可以更容易的定位復現錯誤。更詳細地還可以把裝置的作業系統和型號也在header頭提交。

  • timestamp 請求時間戳

客戶端傳送請求時的時間戳,可用於請求過期判斷。


客戶端簽名細節

  • 防止重放攻擊

雖然signture的存在,使我們可以判斷該請求是否是客戶端生成,從而只響應客戶端的請求。但是這樣安全性還是不夠。

一個典型的攻擊手法時,通過抓包客戶端生成的簽名不斷地進行介面請求。最常見的就是利用該簽名不停地請求簡訊驗證碼介面,直到伺服器的驗證碼餘額耗光。

防止重放攻擊的最有效方式就是保持signture的唯一性。客戶端必須生成唯一的signture,同時服務端也要保證對於一個signture只響應一次請求。

  • 保證客戶端簽名的唯一性

客戶端保證每次生成一個唯一地signture的最簡單方式是在生成簽名的方法中加入時間戳。

服務端保證對每一個signture簽名,只使用一次的實現方法是:對每次請求做判斷,如果該請求攜帶的簽名沒有使用過,則響應該請求,並把該簽名記錄在服務端並標記為已使用。如果查到該請求攜帶的簽名已經被標記為已使用,則不響應該請求。

服務端對signture做記錄,一般通過3種方式:

  1. 寫進檔案
  2. 寫進MySql資料庫
  3. 寫進Redis

寫進檔案的弊端,在於無法供分散式系統使用。MySql的弊端在於請求數多時增加了資料庫的壓力。所以最好的方式還是寫進Redis裡。

  • 增加簽名超時機制

我們保證了客戶端簽名唯一性的方法是將每次請求的signtue記錄在服務端。但是隨著請求的增加,記錄的signture也越來越多。每次請求都要逐一比對以前所有請求記錄下的signture,這樣顯然是不合理。

所以我們正確的方式,不應該是單純把signture寫進檔案,mysql,或redis。而應該是做一個檔案快取,或是寫資料庫臨時表,或是redis快取。

但是如果我們只比對快取期內的signture,攻擊者還是可以通過使用已到期被快取清除的signture來進行重放攻擊。於是我們引入了一個超時機制,如果該請求攜帶的timestamp 比當前的服務端時間相隔已經 大於singture快取時間,則不響應這個請求。這樣就保證了,攻擊者無法使用被快取清除的signture進行重放攻擊。

當然超時時間也不能設定太小,因為客戶端請求到達服務端需要一定的時間。所以超時時間的設定應滿足

(服務端當前時間 - 客戶端提交的timestamp) < 超時時間 < signture快取到期時間
複製程式碼

引入超時機制的另一個好處是防止了一部分的中間人攻擊。因為劫持增加了請求的時間,因為超時機制的存在,可能使被劫持的請求失效。

  • 防止超時時間下溢攻擊

引入了超時機制以後,可能我們一般都會這樣寫

if( (服務端當前時間 - 客戶端提交的timestamp) < 超時時間 ){
    超時了;
}
複製程式碼

但是如果攻擊者 更改了客戶端時間,使客戶端提交的timestamp是一個比服務端時間還超前幾天或是幾年的時間戳,並生成了一個對應的signture。這個時候,當該請求響應後。攻擊者等待一段時間,等快取中這個signture 失效了,攻擊者就可以拿著這個signture和timestamp 進行重放攻擊了,因為這個timestamp 超前了服務端時間幾天或幾年,所以

服務端 - 客戶端提交的timestamp = 負數 < 超時時間
複製程式碼

所以,通過負數小於超時時間,繞過了超時機制,使signture又可以重新使用。當然這種情況下的重放攻擊已經很弱了,因為signture使用過一次就會被快取,所以通過下溢重新使用signture也要等到上一次signture的快取失效了。則兩次攻擊之間,便必須隔一段快取有效期。

所以,更合理的話,除了比對請求時間是否小於超時時間。還應該判斷:

服務端時間 - 客戶端提交的timestamp > 0
複製程式碼
  • 保證客戶端服務端的時間一致性

引入超時機制的前提是客戶端和服務端的時間誤差在可接受的範圍。

試想一下,如果客戶端請求需要0.5s到達服務端,所以服務端的超時時間設定了1s。但是客戶端的時間比服務端慢了2s 。這個時候當客戶端的timestamp 提交到服務端時 本來應該是

服務端當前時間-客戶端提交的timestamp = 0.5 < 1 // 不超時
複製程式碼

結果因為客戶端時間比服務端時間慢了2s,使timestamp 到達服務端時,變成了

服務端當前時間-客戶端提交的timestamp = 2.5s > 1 // 超時
複製程式碼

所以客戶端服務端時間不一致的會造成客戶端所有請求都因為超時而無法響應。

那麼如何保證服務端和客戶端的時間一致性呢? 一個最常用的解決方式就是:

服務端給出一個介面返回當前時間戳,
客戶端請求該介面獲取時間戳,加上該請求的響應時間與當前時間戳相減得出時間差。
而客戶端提交的timestamp 就是當前時間戳加上服務端與客戶端的時間差。
複製程式碼

客戶端簽名生成方式

加密 還是 資訊摘要 ?

  • 資訊摘要

資訊摘要的好處在於服務端處理更簡單,只需要生成對應的簽名進行比對即可。

  • 加密

加密的方式生成簽名常見的可以採用ASE加密,借鑑微信支付寶sdk簽名的生成方式,把header頭的重要的引數,都參與signture的生成。這樣的好處在於更加安全,如果傳輸中header頭的引數被劫持更改,會造成服務端驗籤失敗,則請求自然就不響應了。

Timestamp

timestamp 是要提交10位還是13位的時間戳呢?

這個問題最常見於用PHP寫的APP後端。因為PHP中使用的時間戳是10位的,time()也是隻返回一個10位的時間戳。

但是,我還是認為應該使用一個13位的時間戳。至少你生成signture的時候應使用13位的時間戳,因為這樣實時性更強,防止客戶端太多的時候,不同的客戶端同時生成簽名時,出現signture相同的情況。

所以,反正客戶端使用的是13位的時間戳,如果提交一個10位的時間戳,它也要進行擷取。不如直接提交過來怎麼使用,服務端自己決定的。

<?php

// 13 位時間戳轉10位 進行比對
time() - ceil($timestamp/1000);

// 如果是生成13位時間戳進行比對
list($micro,$time) = expload(' ',microtime());
ceil(($time + $micro)*1000) - $timestamp;

// 更新 其實microtime()是可以直接返回一個float資料,只需要傳一個常數true
ceil(microtime(true)*1000) - $timestamp
複製程式碼

請求錯誤日誌

APP開發的其中一個難點就是錯誤定位難,復現難。所以寫日誌就非常重要了。 當前錯誤日誌所需記錄的資訊應包含至少以下幾類資訊:

  1. 發生錯誤的介面地址和時間
  2. 該請求的header頭中的access_token,其實更合理地應該通過access_token獲取使用者id並記錄,因為access_token是有可能更改的。
  3. 該請求的客戶端版本號,更詳細的話,還有作業系統和裝置型號。這就是為什麼前面提倡將這幾個引數寫在header頭裡每次請求都提交的原因。客戶端版本號可以用來判斷哪個版本請求會出現錯誤,然後再決定如何更改。作業系統和裝置型號主要用於給前端相容性錯誤排查。
  4. 該請求產生錯誤資訊。
  5. 該請求的http狀態碼。
  6. 業務層如果有錯誤狀態碼也需要記錄

請求返回格式

APP開發現在比較流行的還是返回json格式而不是xml格式。

返回json格式的資料一般是這樣的:

{
    "status" :200,
    "message":"ok",
    "data"   :{},
}
status 返回請求狀態碼,一般複用http狀態碼。
message 返回請求訊息,如果有錯誤這裡寫錯誤資訊。
data 是返回的資料
複製程式碼

但是我還是比較喜歡以下這種返回方式

{
    "code":0
    "data":{}
}
// 為什麼請求成功 要使用0作為code狀態碼呢,0的第一感覺不是false嗎?
嗯,錯誤情況千千萬,而成功只有一種情況。正數負數千千萬,而0也只有一個。
{
    "code":1001
    "message":"某個控制器請求出錯"
}
複製程式碼

為什麼呢?

因為不想用http status來傳達API請求狀態,http status 傳達的是通訊層的狀態。API是為了滿足業務,返回的資料應包含業務層的狀態碼。業務層不和通訊層耦合,不拿http status 取巧。

當然對於這點,喜歡使用http status 的同學也有不同的看法,這就看個人的喜好了。

我覺得使用code的好處在於:

  1. 我們可以自定義更多的狀態碼和錯誤資訊。一般我會做一個介面錯誤地圖類,然後根據code的值獲取對應的message。
  2. 更好地對code進行分類定義,比如1000 開頭的表示 a 控制器各個介面的產生的各種錯誤 2000 開頭的表示 b控制器各個介面產生的各種錯誤。 -1 表示 錯誤地圖類中 未定義的錯誤。
  3. 業務層的狀態碼不和通訊層狀態碼耦合,更詳細地展示業務層錯誤資訊。
  4. 避免客戶端出現某個介面返回未考慮進去的非200 ok的http 狀態碼,而造成客戶端卡死的情況。我喜歡在後端對http響應的狀態碼進行判斷,如果該請求的響應碼不是200 就把檢視錯誤地圖類轉化為對應的code狀態碼和錯誤message,寫入日誌,並把http 狀態碼改回200。這樣保證每次http請求基本都會返回200,可預知的錯誤都轉化為返回的json資料中的code狀態碼。

Authorization

  • App後端開發不能使用session?

雖然app通過介面請求的方式與後端互動,沒有cookie,但是依然可以使用session。session的實現不依賴於cookie,如果你把cookie中的session_id 但是開啟session的令牌。那麼header頭中的Authorization 欄位提交的access_token 同樣可以看成令牌實現同樣的作用。

  • 是否允許賬戶同時在兩個以上的裝置登陸

因為我們通過Authorization來獲取認證,所以:

  1. 如果你允許同時登陸多臺裝置,你只需要登陸後複用user表中的access_token。

  2. 如果那你不允許同時登陸多臺裝置,則可以選擇登陸時重新整理access_token,這樣就使得其他線上的裝置請求頭中的Authorization欄位提交的access_token與user表中的不匹配,自然就被擠下線了。

  • access_token的安全性問題

我們通過access_token來獲取使用者,也就意味著access_token如果被劫持就等同於使用者的賬戶被盜。

你想想同樣作為獲取服務端session的令牌,使用cookie時,為了安全我們一般會做哪些呢?

  1. cookie在生成時就會被指定一個Expire值,這就是cookie的生存週期,在這個週期內cookie有效,超出週期cookie就會被清除
  2. 對cookie進行加密,嵌入時間戳保證每次加密後的密文不同
  3. 不允許跨域使用

所以,雖然signture的唯一性已經為我們證明了是APP發起的合法請求,但是嚴格來說我們也不能單單對access_token 進行明文傳輸。 我們可以考慮在Authorization 欄位不是簡單地傳輸access_token的值,可以傳一個access_token和時間戳的加密字串,在服務端再進行解密,並先判斷是否超時。如果要安全性高些,還可以參考signture做唯一性處理。


版本升級

建議建一個版本升級表用來存放版本升級資訊。並且要有是否強制更新欄位。

我們header頭提交version引數,寫日誌為的都是不想失去對客戶端的控制,能更好的定位錯誤。但是app與傳統的web開發的一個區別,就是web開發頁面做了修改,所有的使用者都能看到修改,但是APP的話,只要使用者沒有更新,已修復的bug,對使用者而言 依舊存在。

版本升級表設計

欄位名 型別 備註
id int 主鍵id
app_type varchar 客戶端版本型別 ios or android
version int 開發版本號
version_code varchar 客戶端版本號(1.0.2)
upagrade_desc varchar 更新提示語
apk_url varchar 更新包連結
is_force tinyint 是否強制更新
created_at int 建立時間
status tinyint 是否已釋出

有了版本升級表以後,我們就能更方便直觀地管理檢視我們釋出的版本。

而且我們可以在開啟APP時請求介面,查詢版本設計表獲得最新的版本與header頭提交的version欄位作對比,判斷是否需要更新,彈出更新視窗。

對於需要強制更新的版本,彈窗應設定為不允許使用者點選取消,一定要更新才能使用該APP。這樣我們就可以把一些重大更新或者修復一些重要bug的版本設為強制更新,不更新就不讓繼續使用。

使用者分析

為了更好地進行使用者分析,我們還可以建一個APP登陸記錄表。 開啟APP時就通過header把使用者資訊記錄起來,用來做使用者分析。使用者日活量,月活量。

客戶端一開啟就將資料發給該介面就行,不管請求是否成功,客戶端都不需要關心。

app_active_log 表

欄位名 型別 備註
id int 主鍵id
app_type varchar 客戶端版本型別 ios or android
version int 開發版本號
version_code varchar 客戶端版本號(1.0.2)
model varchar 裝置型號 小米 蘋果
uid int 使用者id
created_at int 建立時間

這個表的另一個功能還可以統計某個版本的使用者量或是Android還是IOS使用者多,方便我們更新版本時選擇先開發IOS版或是安卓版,或者出現bug決定哪個版本先修復。

客戶端異常監控,分析

常見的APP端異常:

  1. crash 使用APP過程中突然出現閃退
  2. 卡頓 出現畫面卡頓
  3. Exception 程式被catch起來的Exception
  4. ANR 出現提示無響應彈框(Android)

我們在服務端寫日誌,在header頭提交裝置資訊這些都是為了更好地定位客戶端的錯誤。但是我們的日誌只能記錄介面呼叫異常。對於客戶端的異常卻無能為力。

為此,我們應該和客戶端配合。把客戶端產生異常定期上報到服務端。方便客戶端工程師定位復現並修復客戶端異常

我們可以建一個 app_crap 表來統計收集 crash 卡頓 Exception ANR的次數和影響使用者量 使用者數

欄位名 型別 備註
id int 主鍵id
app_type varchar 客戶端版本型別 ios or android
version int 開發版本號
version_code varchar 客戶端版本號(1.0.2)
model varchar 裝置型號 小米 蘋果
type tinyint 端異常型別 卡頓 閃退
description varchar 描述
created_at int 建立時間

當然,客戶端記錄這些資料比較麻煩.一個更好的解決方案是在客戶端中整合第三方服務提供的SDK,將這些資料提交到第三方平臺,客戶端工程師可以登入第三方平臺檢視客戶端異常統計。

常用的第三方平臺 :

  1. 聽雲
  2. OneAPM

訊息推送

  • 原生方式
  1. 客戶端輪詢 不推薦
  2. 服務端主動推客戶端 實現難度大
  • 第三方推送服務
  1. 極光推送 推薦使用restful api介面 比其他SDK用起來更方便
  2. 百度雲推送
  3. 信鴿

APP後端開發工具推薦

介面除錯神器,發起一個http請求

抓包神器,可以抓APP傳送過來的請求,檢視是否有請求提交的引數都是什麼

php一個http 請求包,通過composer 安裝快速使用,可用來寫介面的測試程式碼,模擬發起http請求,比起postman的優點在於,通過程式碼實現,自定義更方便。

手機模擬器,可以在電腦上模擬多個Android系統的手機

內網對映工具。app開發一個麻煩的地方在於無法本地除錯,因為客戶端需要請求有域名或公網ip的服務端程式碼,雖然公司有測試伺服器,但是有些時候測試服上有很多人同時使用,我git提交了修改後的服務端程式碼不能馬上reset hard生效。或者測試服不在我開發的分支。ngrok的好處是內網對映,給你的電腦繫結一個域名。而客戶端測試時填寫這個域名能訪問到你電腦的服務端程式碼,實時除錯更方便。


以上就是我做APP後端開發的一些總結,由於是第一次開發APP後端,水平有限,還請大家多多指教

相關文章