大家好,我是碼農先森。 回想起以前用模版渲染數據的歲月,那時都沒有 API 介面開發的概念。PHP 服務端和前端 HTML、CSS、JS 代碼混合式開發,也不分前端、後端程式員,大家都是全乾工程師。隨著前後端分離、移動端開發的興起,用後端渲染數據的開發方式效率低下,已經不能滿足業務對需求快速上線的要 ...
大家好,我是碼農先森。
回想起以前用模版渲染數據的歲月,那時都沒有 API 介面開發的概念。PHP 服務端和前端 HTML、CSS、JS 代碼混合式開發,也不分前端、後端程式員,大家都是全乾工程師。隨著前後端分離、移動端開發的興起,用後端渲染數據的開發方式效率低下,已經不能滿足業務對需求快速上線的要求了。於是為了前後端的高效協同開發引入了 API 介面,只要在開發需求之前約定好數據傳參,之後便可以開始啟動自己的開發任務且互不幹涉,最後再進行統一的介面聯調。
根據熵增原則,如果任何事情不加以規則來限制,則都會朝著泛濫的方式發展。同樣 API 介面開發也會出現這樣的情況,由於每個人的開發習慣不同,導致 API 介面的開發格式五花八門,聯調過程困難重重。無規矩不成方圓,因此為了規範 API 介面開發的形式,同時也結合我平時的項目開發經驗。總結了一些 API 介面開發的實踐經驗,希望對大家能有所幫助。
話不多說,開整!
這次主要的實踐內容是 API 介面簽名設計,以下是一些關鍵的步驟:
- 給前端分配一個 AppKey,這個 AppKey 需要帶在 HTTP Header 頭中進行傳輸。
- 在前端的傳參中需要額外增加 時間戳 timestamp、隨機字元串 nonce 參數。
- 將前端的所有參數排序後拼接成一個字元串,再使用 MD5 加密函數生成 sign 簽名字元串。
- 服務端接收到參數後,先驗證 AppKey 是否一致。
- 再驗證前端所傳的時間戳參數是否還在有效期。
- 之後在服務端使用同樣的加密演算法生成 sign 簽名串,再與前端的 sign 簽名串比對。
- 最後判斷前端所傳的隨機字元串是否已被使用,一次請求有效。
接下來開始在 ThinkPHP 和 Gin 框架中進行實現,文中只展示了核心的代碼,完整代碼的獲取方式放在了文章末尾。
我們先熟悉一下項目結構核心的目錄,有助於理解文中的內容。一個正常的請求首先要經過路由 route 再到中間件 middleware 最後到控制器 controller,API 介面的簽名驗證是在中間件 middleware 中實現,作為一個中間層在整個請求鏈路中起著承上啟下的重要作用。
[manongsen@root php_to_go]$ tree -L 2
.
├── go_sign
│ ├── app
│ │ ├── controller
│ │ │ └── user.go
│ │ ├── middleware
│ │ │ └── api_sign.go
│ │ ├── config.go
│ │ └── route.go
│ ├── go.mod
│ ├── go.sum
│ └── main.go
└── php_sign
│ ├── app
│ │ ├── controller
│ │ │ └── User.php
│ │ ├── middleware
│ │ │ └── ApiSign.php
│ │ └── middleware.php
│ ├── composer.json
│ ├── composer.lock
│ ├── config
│ ├── route
│ │ └── app.php
│ ├── think
│ ├── vendor
│ └── .env
ThinkPHP
使用 composer 創建基於 ThinkPHP 框架的 php_sign 項目。
[manongsen@root ~]$ pwd
/home/manongsen/workspace/php_to_go/php_sign
[manongsen@root php_sign]$ composer create-project topthink/think php_sign
隨機字元串需要用到 Redis 進行存儲,所以這裡需要安裝 Redis 擴展包,便於操作 Redis。
[manongsen@root php_sign]$ composer require predis/predis
在項目 php_sign 下創建 ApiSign 中間件。
[manongsen@root php_sign]$ php think make:middleware ApiSign
Middleware:app\middleware\ApiSign created successfully.
在項目 php_sign 下複製一個 env 配置文件,並且定義好 AppKey。
[manongsen@root php_sign]$ cp .example.env .env
API 介面簽名的驗證是放在框架的中間件中進行實現的,其中時間戳的有效時間設置的是 2 秒,有些朋友會有疑惑為什麼是 2 秒?3 秒、5 秒不行嗎?這裡的有效時間是基於網路通信的延時考慮的,根據普遍情況延時大概是 2 秒。如果你的服務延時比較長,也可以設置長一些,並沒有一個定量的值,話說到這裡也提醒一下如果你的介面延時超過 2 秒,大概率需要優化一下代碼了。此外,還有一個隨機字元串參數,這個參數的目的是為了防止介面被重放,如果做過爬蟲的朋友可能對這個會深有感觸,這也是防範爬蟲的一種手段。
<?php
declare (strict_types = 1);
namespace app\middleware;
use think\facade\Env;
use think\facade\Cache;
class ApiSign
{
/**
* 處理請求
*
* @param \think\Request $request
* @param \Closure $next
* @return Response
*/
public function handle($request, \Closure $next)
{
/*********************** 驗證AppKey參數 ******************/
$headers = $request->header();
if (!isset($headers["app-key"])) {
return json(["code" => 400, "msg" => "秘鑰參數缺失"]);
}
$reqAppKey = $headers["app-key"];
$vfyAppKey = Env::get("APP_KEY");
if ($reqAppKey != $vfyAppKey) {
return json(["code" => 400, "msg" => "簽名秘鑰無效"]);
}
/*********************** 驗證時間戳參數 *******************/
$params = $request->param();
if (!isset($params["timestamp"])) {
return json(["code" => 400, "msg" => "時間參數缺失"]);
}
$timestamp = $params["timestamp"];
$nowTime = time();
if (($nowTime-$timestamp) > 2) {
return json(["code" => 400, "msg" => "時間參數過期"]);
}
/*********************** 驗證簽名串參數 *******************/
if (!isset($params["sign"])) {
return json(["code" => 400, "msg" => "簽名參數缺失"]);
}
$reqSign = $params["sign"];
unset($params["sign"]);
// 將參數進行排序
ksort($params);
$paramStr = http_build_query($params);
// md5 加密處理
$vfySign = md5($paramStr . "&app_key={$vfyAppKey}");
// 比較簽名參數
if ($reqSign != $vfySign) {
return json(["code" => 400, "msg" => "簽名驗證失敗"]);
}
/*********************** 驗證隨機串參數 *******************/
if (!isset($params["nonce_str"])) {
return json(["code" => 400, "msg" => "隨機串參數缺失"]);
}
$nonceStr = $params["nonce_str"];
// 判斷 nonce_str 隨機字元串是否被使用
$redis = Cache::store('redis')->handler();
$flag = $redis->exists($nonceStr);
if ($flag) {
return json(["code" => 400, "msg" => "隨機串參數無效"]);
}
// 存儲 nonce_str 隨機字元串
$redis->set($nonceStr, $timestamp, 2);
return $next($request);
}
}
啟動 php_sign 服務。
[manongsen@root php_sign]$ php think run
ThinkPHP Development server is started On <http://0.0.0.0:8000/>
You can exit with `CTRL-C`
Document root is: /home/manongsen/workspace/php_to_go/php_sign/public
[Wed Jul 3 22:02:16 2024] PHP 8.3.4 Development Server (http://0.0.0.0:8000) started
使用 Postman 工具進行測試驗證,通過構造正確的參數,便可以成功的返回數據。
Gin
通過 go mod 初始化 go_sign 項目。
[manongsen@root ~]$ pwd
/home/manongsen/workspace/php_to_go/go_sign
[manongsen@root go_sign]$ go mod init go_sign
安裝 Gin 框架庫,這裡與 ThinkPHP 不一樣的是 Gin 框架是以第三庫的形式在 gin_sign 項目中進行引用的。
[manongsen@root go_sign]$ go get github.com/gin-gonic/gin
安裝 Redis 操作庫,與在 ThinkPHP 框架中一樣也要使用到 Redis。
[manongsen@root go_sign]$ go get github.com/go-redis/redis
這是在 Gin 框架中利用中間件來進行 API 介面簽名驗證,從代碼量上來看就比 PHP 要多了。其中還需要自行合併 GET 和 POST 參數,方便在中間件中統一進行簽名處理。對參數的拼接也沒有類似 http_build_query 的方法,總體上來說在 Go 中進行簽名驗證需要繁瑣不少。
package middleware
import (
"bytes"
"crypto/md5"
"encoding/json"
"fmt"
"go_sign/app"
"io/ioutil"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
func ApiSign() gin.HandlerFunc {
return func(c *gin.Context) {
/*************************** 驗證AppKey參數 **************************/
reqAppKey := c.Request.Header.Get("app-key")
if len(reqAppKey) == 0 {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "秘鑰參數缺失"})
c.Abort()
return
}
vfyAppKey := app.APP_KEY
if reqAppKey != vfyAppKey {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "秘鑰參數無效"})
c.Abort()
return
}
// 獲取請求參數
params := mergeParams(c)
/*************************** 驗證時間戳參數 **************************/
if _, ok := params["timestamp"]; !ok {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "時間參數無效"})
c.Abort()
return
}
timestampStr := fmt.Sprintf("%v", params["timestamp"])
timestampInt, err := strconv.ParseInt(timestampStr, 0, 64)
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "時間參數無效"})
c.Abort()
return
}
nowTime := time.Now().Unix()
if nowTime-timestampInt > 2 {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "時間參數過期"})
c.Abort()
return
}
/*************************** 驗證簽名串參數 **************************/
if _, ok := params["sign"]; !ok {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "簽名參數無效"})
c.Abort()
return
}
reqSign := fmt.Sprintf("%v", params["sign"])
// 針對 dataMap 進行排序
dataMap := params
keys := make([]string, len(dataMap))
i := 0
for k := range dataMap {
keys[i] = k
i++
}
sort.Strings(keys)
var buf bytes.Buffer
for _, k := range keys {
if k != "sign" && !strings.HasPrefix(k, "reserved") {
buf.WriteString(k)
buf.WriteString("=")
buf.WriteString(fmt.Sprintf("%v", dataMap[k]))
buf.WriteString("&")
}
}
bufStr := buf.String()
dataStr := bufStr + "app_key=" + app.APP_KEY
// 進行 md5 加密處理
data := []byte(dataStr)
has := md5.Sum(data)
vfySign := fmt.Sprintf("%x", has) // 將[]byte轉成16進位
if reqSign != vfySign {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "簽名驗證失敗"})
c.Abort()
return
}
/*************************** 驗證隨機串參數 **************************/
if _, ok := params["nonce_str"]; !ok {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "隨機串參數缺失"})
c.Abort()
return
}
nonceStr := fmt.Sprintf("%v", params["nonce_str"])
// 判斷是否存在 nonce_str 隨機字元串
flag, _ := app.RedisConn.Exists(nonceStr).Result()
if flag > 0 {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "隨機串參數無效"})
c.Abort()
return
}
// 存儲nonce_str隨機字元串
app.RedisConn.Set(nonceStr, timestampInt, time.Second*2).Result()
c.Next()
}
}
// 將 GET 和 POST 的參數合併到同一 Map
func mergeParams(c *gin.Context) map[string]interface{} {
var (
dataMap = make(map[string]interface{})
queryMap = make(map[string]interface{})
postMap = make(map[string]interface{})
)
contentType := c.ContentType()
for k := range c.Request.URL.Query() {
queryMap[k] = c.Query(k)
}
if contentType == "application/json" {
if c.Request != nil && c.Request.Body != nil {
bodyBytes, _ := ioutil.ReadAll(c.Request.Body)
if len(bodyBytes) > 0 {
if err := json.NewDecoder(bytes.NewBuffer(bodyBytes)).Decode(&postMap); err != nil {
return nil
}
c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))
}
}
} else if contentType == "multipart/form-data" {
for k, v := range c.Request.PostForm {
if len(v) > 1 {
postMap[k] = v
} else if len(v) == 1 {
postMap[k] = v[0]
}
}
} else {
for k, v := range c.Request.PostForm {
if len(v) > 1 {
postMap[k] = v
} else if len(v) == 1 {
postMap[k] = v[0]
}
}
}
// 優先順序:以post優先順序最高,會覆蓋get參數
for k, v := range queryMap {
dataMap[k] = v
}
for k, v := range postMap {
dataMap[k] = v
}
return dataMap
}
啟動 gin_sin 服務。
[manongsen@root go_sign]$ go run main.go
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)
[GIN-debug] GET /user/info --> go_sign/app/controller.UserInfo (4 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :8001
同樣也使用 Postman 工具進行測試驗證,通過構造正確的參數,便可以成功的返回數據。
結語
數據安全一直是個熱門的話題,API 介面在數據的傳輸上扮演著至關重要的角色。為了 API 介面的安全性、健壯性,完整性,往往需要將網路上的數據進行簽名加密傳輸。同時為了防止 API 介面被重放爬蟲偽造等類似惡意攻擊的手段,還要在介面設計時增加有效時間、隨機字元串、簽名串等參數,來保障數據的安全性。這一次的 API 介面簽名設計實踐,大家也可以手動嘗試實驗一下,希望對大家的日常工作能有所幫助。最後感興趣的朋友可以在微信公眾號內回覆「4867」獲取完整的實踐代碼。
歡迎關註、分享、點贊、收藏、在看,我是微信公眾號「碼農先森」作者。