根據(jù)熵增原則,如果任何事情不加以規(guī)則來(lái)限制,則都會(huì)朝著泛濫的方式發(fā)展。同樣 API 接口開發(fā)也會(huì)出現(xiàn)這樣的情況,由于每個(gè)人的開發(fā)習(xí)慣不同,導(dǎo)致 API 接口的開發(fā)格式五花八門,聯(lián)調(diào)過(guò)程困難重重。無(wú)規(guī)矩不成方圓,因此為了規(guī)范 API 接口開發(fā)的形式,同時(shí)也結(jié)合我平時(shí)的項(xiàng)目開發(fā)經(jīng)驗(yàn)。總結(jié)了一些 API 接口開發(fā)的實(shí)踐經(jīng)驗(yàn),希望對(duì)大家能有所幫助。

話不多說(shuō),開整!

這次主要的實(shí)踐內(nèi)容是 API 接口簽名設(shè)計(jì),以下是一些關(guān)鍵的步驟:

接下來(lái)開始在 ThinkPHP 和 Gin 框架中進(jìn)行實(shí)現(xiàn),文中只展示了核心的代碼,完整代碼的獲取方式放在了文章末尾。

我們先熟悉一下項(xiàng)目結(jié)構(gòu)核心的目錄,有助于理解文中的內(nèi)容。一個(gè)正常的請(qǐng)求首先要經(jīng)過(guò)路由 route 再到中間件 middleware 最后到控制器 controller,API 接口的簽名驗(yàn)證是在中間件 middleware 中實(shí)現(xiàn),作為一個(gè)中間層在整個(gè)請(qǐng)求鏈路中起著承上啟下的重要作用。

[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 創(chuàng)建基于 ThinkPHP 框架的 php_sign 項(xiàng)目。

[manongsen@root ~]$ pwd
/home/manongsen/workspace/php_to_go/php_sign
[manongsen@root php_sign]$ composer create-project topthink/think php_sign

隨機(jī)字符串需要用到 Redis 進(jìn)行存儲(chǔ),所以這里需要安裝 Redis 擴(kuò)展包,便于操作 Redis。

[manongsen@root php_sign]$ composer require predis/predis

在項(xiàng)目 php_sign 下創(chuàng)建 ApiSign 中間件。

[manongsen@root php_sign]$ php think make:middleware ApiSign
Middleware:app\middleware\ApiSign created successfully.

在項(xiàng)目 php_sign 下復(fù)制一個(gè) env 配置文件,并且定義好 AppKey。

[manongsen@root php_sign]$ cp .example.env .env

API 接口簽名的驗(yàn)證是放在框架的中間件中進(jìn)行實(shí)現(xiàn)的,其中時(shí)間戳的有效時(shí)間設(shè)置的是 2 秒,有些朋友會(huì)有疑惑為什么是 2 秒?3 秒、5 秒不行嗎?這里的有效時(shí)間是基于網(wǎng)絡(luò)通信的延時(shí)考慮的,根據(jù)普遍情況延時(shí)大概是 2 秒。如果你的服務(wù)延時(shí)比較長(zhǎng),也可以設(shè)置長(zhǎng)一些,并沒有一個(gè)定量的值,話說(shuō)到這里也提醒一下如果你的接口延時(shí)超過(guò) 2 秒,大概率需要優(yōu)化一下代碼了。此外,還有一個(gè)隨機(jī)字符串參數(shù),這個(gè)參數(shù)的目的是為了防止接口被重放,如果做過(guò)爬蟲的朋友可能對(duì)這個(gè)會(huì)深有感觸,這也是防范爬蟲的一種手段。

<?php
declare (strict_types = 1);

namespace app\middleware;

use think\facade\Env;
use think\facade\Cache;

class ApiSign
{
/**
* 處理請(qǐng)求
*
* @param \think\Request $request
* @param \Closure $next
* @return Response
*/
public function handle($request, \Closure $next)
{
/*********************** 驗(yàn)證AppKey參數(shù) ******************/
$headers = $request->header();
if (!isset($headers["app-key"])) {
return json(["code" => 400, "msg" => "秘鑰參數(shù)缺失"]);
}
$reqAppKey = $headers["app-key"];
$vfyAppKey = Env::get("APP_KEY");
if ($reqAppKey != $vfyAppKey) {
return json(["code" => 400, "msg" => "簽名秘鑰無(wú)效"]);
}

/*********************** 驗(yàn)證時(shí)間戳參數(shù) *******************/
$params = $request->param();
if (!isset($params["timestamp"])) {
return json(["code" => 400, "msg" => "時(shí)間參數(shù)缺失"]);
}
$timestamp = $params["timestamp"];
$nowTime = time();
if (($nowTime-$timestamp) > 2) {
return json(["code" => 400, "msg" => "時(shí)間參數(shù)過(guò)期"]);
}

/*********************** 驗(yàn)證簽名串參數(shù) *******************/
if (!isset($params["sign"])) {
return json(["code" => 400, "msg" => "簽名參數(shù)缺失"]);
}
$reqSign = $params["sign"];
unset($params["sign"]);
// 將參數(shù)進(jìn)行排序
ksort($params);
$paramStr = http_build_query($params);
// md5 加密處理
$vfySign = md5($paramStr . "&app_key={$vfyAppKey}");
// 比較簽名參數(shù)
if ($reqSign != $vfySign) {
return json(["code" => 400, "msg" => "簽名驗(yàn)證失敗"]);
}

/*********************** 驗(yàn)證隨機(jī)串參數(shù) *******************/
if (!isset($params["nonce_str"])) {
return json(["code" => 400, "msg" => "隨機(jī)串參數(shù)缺失"]);
}
$nonceStr = $params["nonce_str"];

// 判斷 nonce_str 隨機(jī)字符串是否被使用
$redis = Cache::store('redis')->handler();
$flag = $redis->exists($nonceStr);
if ($flag) {
return json(["code" => 400, "msg" => "隨機(jī)串參數(shù)無(wú)效"]);
}

// 存儲(chǔ) nonce_str 隨機(jī)字符串
$redis->set($nonceStr, $timestamp, 2);
return $next($request);
}
}

啟動(dòng) php_sign 服務(wù)。

[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 工具進(jìn)行測(cè)試驗(yàn)證,通過(guò)構(gòu)造正確的參數(shù),便可以成功的返回?cái)?shù)據(jù)。

Gin

通過(guò) go mod 初始化 go_sign 項(xiàng)目。

[manongsen@root ~]$ pwd
/home/manongsen/workspace/php_to_go/go_sign
[manongsen@root go_sign]$ go mod init go_sign

安裝 Gin 框架庫(kù),這里與 ThinkPHP 不一樣的是 Gin 框架是以第三庫(kù)的形式在 gin_sign 項(xiàng)目中進(jìn)行引用的。

[manongsen@root go_sign]$ go get github.com/gin-gonic/gin

安裝 Redis 操作庫(kù),與在 ThinkPHP 框架中一樣也要使用到 Redis。

[manongsen@root go_sign]$ go get github.com/go-redis/redis

這是在 Gin 框架中利用中間件來(lái)進(jìn)行 API 接口簽名驗(yàn)證,從代碼量上來(lái)看就比 PHP 要多了。其中還需要自行合并 GETPOST 參數(shù),方便在中間件中統(tǒng)一進(jìn)行簽名處理。對(duì)參數(shù)的拼接也沒有類似 http_build_query 的方法,總體上來(lái)說(shuō)在 Go 中進(jìn)行簽名驗(yàn)證需要繁瑣不少。

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) {
/*************************** 驗(yàn)證AppKey參數(shù) **************************/
reqAppKey := c.Request.Header.Get("app-key")
if len(reqAppKey) == 0 {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "秘鑰參數(shù)缺失"})
c.Abort()
return
}
vfyAppKey := app.APP_KEY
if reqAppKey != vfyAppKey {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "秘鑰參數(shù)無(wú)效"})
c.Abort()
return
}

// 獲取請(qǐng)求參數(shù)
params := mergeParams(c)

/*************************** 驗(yàn)證時(shí)間戳參數(shù) **************************/
if _, ok := params["timestamp"]; !ok {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "時(shí)間參數(shù)無(wú)效"})
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": "時(shí)間參數(shù)無(wú)效"})
c.Abort()
return
}

nowTime := time.Now().Unix()
if nowTime-timestampInt > 2 {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "時(shí)間參數(shù)過(guò)期"})
c.Abort()
return
}

/*************************** 驗(yàn)證簽名串參數(shù) **************************/
if _, ok := params["sign"]; !ok {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "簽名參數(shù)無(wú)效"})
c.Abort()
return
}
reqSign := fmt.Sprintf("%v", params["sign"])

// 針對(duì) dataMap 進(jìn)行排序
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

// 進(jìn)行 md5 加密處理
data := []byte(dataStr)
has := md5.Sum(data)
vfySign := fmt.Sprintf("%x", has) // 將[]byte轉(zhuǎn)成16進(jìn)制
if reqSign != vfySign {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "簽名驗(yàn)證失敗"})
c.Abort()
return
}

/*************************** 驗(yàn)證隨機(jī)串參數(shù) **************************/
if _, ok := params["nonce_str"]; !ok {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "隨機(jī)串參數(shù)缺失"})
c.Abort()
return
}
nonceStr := fmt.Sprintf("%v", params["nonce_str"])

// 判斷是否存在 nonce_str 隨機(jī)字符串
flag, _ := app.RedisConn.Exists(nonceStr).Result()
if flag > 0 {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "隨機(jī)串參數(shù)無(wú)效"})
c.Abort()
return
}

// 存儲(chǔ)nonce_str隨機(jī)字符串
app.RedisConn.Set(nonceStr, timestampInt, time.Second*2).Result()

c.Next()
}
}

// 將 GET 和 POST 的參數(shù)合并到同一 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]
}
}
}

// 優(yōu)先級(jí):以post優(yōu)先級(jí)最高,會(huì)覆蓋get參數(shù)
for k, v := range queryMap {
dataMap[k] = v
}
for k, v := range postMap {
dataMap[k] = v
}

return dataMap
}

啟動(dòng) gin_sin 服務(wù)。

[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 工具進(jìn)行測(cè)試驗(yàn)證,通過(guò)構(gòu)造正確的參數(shù),便可以成功的返回?cái)?shù)據(jù)。

結(jié)語(yǔ)

數(shù)據(jù)安全一直是個(gè)熱門的話題,API 接口在數(shù)據(jù)的傳輸上扮演著至關(guān)重要的角色。為了 API 接口的安全性、健壯性,完整性,往往需要將網(wǎng)絡(luò)上的數(shù)據(jù)進(jìn)行簽名加密傳輸。同時(shí)為了防止 API 接口被重放爬蟲偽造等類似惡意攻擊的手段,還要在接口設(shè)計(jì)時(shí)增加有效時(shí)間、隨機(jī)字符串、簽名串等參數(shù),來(lái)保障數(shù)據(jù)的安全性。這一次的 API 接口簽名設(shè)計(jì)實(shí)踐,大家也可以手動(dòng)嘗試實(shí)驗(yàn)一下,希望對(duì)大家的日常工作能有所幫助。

本文章轉(zhuǎn)載微信公眾號(hào)@碼農(nóng)先森

上一篇:

如何在軟件開發(fā)中實(shí)施API First標(biāo)準(zhǔn)

下一篇:

使用gin搭建api后臺(tái)系統(tǒng)之框架搭建
#你可能也喜歡這些API文章!

我們有何不同?

API服務(wù)商零注冊(cè)

多API并行試用

數(shù)據(jù)驅(qū)動(dòng)選型,提升決策效率

查看全部API→
??

熱門場(chǎng)景實(shí)測(cè),選對(duì)API

#AI文本生成大模型API

對(duì)比大模型API的內(nèi)容創(chuàng)意新穎性、情感共鳴力、商業(yè)轉(zhuǎn)化潛力

25個(gè)渠道
一鍵對(duì)比試用API 限時(shí)免費(fèi)

#AI深度推理大模型API

對(duì)比大模型API的邏輯推理準(zhǔn)確性、分析深度、可視化建議合理性

10個(gè)渠道
一鍵對(duì)比試用API 限時(shí)免費(fèi)