大家好,我是码农先森。

回想起以前用模版渲染数据的岁月,那时都没有 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」获取完整的实践代码。


欢迎关注、分享、点赞、收藏、在看,我是微信公众号「码农先森」作者。

PHP转Go系列 | ThinkPHP与Gin框架之API接口签名设计实践的更多相关文章

  1. Http下的各种操作类.WebApi系列~通过HttpClient来调用Web Api接口

    1.WebApi系列~通过HttpClient来调用Web Api接口 http://www.cnblogs.com/lori/p/4045413.html HttpClient使用详解(java版本 ...

  2. PHP: thinkPHP踩坑记录(实现API接口以及处理莫名其妙的500问题)

    因为各种原因开始学习PHP,并且要在两周内能够对PHP项目进行二次开发,还好PHP够简单,至少入门很简单,很快就接触thinkPHP框架. 在了解了路由匹配视图的规则之后,开始着手尝试编写API接口, ...

  3. 如何给框架添加API接口日志

    前言 用的公司的框架,是MVC框架,看了下里面的日志基类,是操作日志,对增删改进行记录, 夸张的是一张业务的数据表 需要一张专门的日志表进行记录, 就是说你写个更新,添加的方法都必须写一遍操作日志,代 ...

  4. android框架Java API接口总注释/**@hide*/和internal API

    Android有两种类型的API是不能经由SDK访问的 l 第一种是位于com.android.internal包中的API我,位于frameworks/base/core/java/com/andr ...

  5. WebApi系列~通过HttpClient来调用Web Api接口

    回到目录 HttpClient是一个被封装好的类,主要用于Http的通讯,它在.net,java,oc中都有被实现,当然,我只会.net,所以,只讲.net中的HttpClient去调用Web Api ...

  6. WebApi系列~通过HttpClient来调用Web Api接口~续~实体参数的传递

    回到目录 上一讲中介绍了使用HttpClient如何去调用一个标准的Web Api接口,并且我们知道了Post,Put方法只能有一个FromBody参数,再有多个参数时,上讲提到,需要将它封装成一个对 ...

  7. WebApi系列~通过HttpClient来调用Web Api接口~续~实体参数的传递 【转】

    原文:http://www.cnblogs.com/lori/p/4045633.html 下面定义一个复杂类型对象 public class User_Info { public int Id { ...

  8. soapUI系列之—-07 调用JIRA Rest API接口【例】

    一.调用JIRA接口------实现过滤器搜索问题 1. 在SoapUI中新建 REST Project, 在URI 中输入登录接口的 url (任意一个 Rest 接口的 url 都可以): 2. ...

  9. Gin 框架 - 安装和路由配置

    目录 概述 Gin 安装 路由配置 推荐阅读 概述 看下 Gin 框架的官方介绍: Gin 是一个用 Go (Golang) 编写的 web 框架. 它是一个类似于 martini 但拥有更好性能的 ...

  10. [系列] Gin框架 - 数据绑定和验证

    目录 概述 推荐阅读 概述 上篇文章分享了 Gin 框架使用 Logrus 进行日志记录,这篇文章分享 Gin 框架的数据绑定与验证. 有读者咨询我一个问题,如何让框架的运行日志不输出控制台? 解决方 ...

随机推荐

  1. windows下安装Psycopg2,用于python连接postgresql

    1.首先,下载Windows版的Psycopg2.进入https://pypi.python.org/pypi/psycopg2/ ,在下载文件列表中选择psycopg2-2.7.4-cp33-cp3 ...

  2. bond网卡

    目录 一.bond概述 1.1.bond的优点 二.bond模式 2.1.mode=0 2.2.mode=1 2.3.mode=2 2.4.mode=3 2.5.mode=4 2.6.mode=5 2 ...

  3. WPF 实现触摸滑动功能

    自定义ScrollViewer的Touch事件--触摸上下移动ScrollViewer滚动到指定位置   1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ...

  4. 面试必会->Redis篇

    01- 你们项目中哪里用到了Redis ? 在我们的项目中很多地方都用到了Redis , Redis在我们的项目中主要有三个作用 : 使用Redis做热点数据缓存/接口数据缓存 使用Redis存储一些 ...

  5. docker——容器的基本操作

    docker 容器的基本操作 run 格式 docker run [选项] 镜像 [命令] [参数...] 选项 选项 解释 -d 后台运行 -i 交互模式 -t 分配一个伪终端 -p 设置端口 -- ...

  6. 使用python批量获取excel的sheet名称

    这个脚本的重用是批量读取excel并获取每个excel的所有sheet名输出到一个文件中. 环境:python 3.7.3 1 # -*- coding:utf-8 -*- 2 3 ''' 4 本代码 ...

  7. 解决 Https 站点请求 Http 接口服务后报 the content must be served over HTTPS 错误的问题

    问题分析 之前将自己所有的 Http 站点全部更新为 Https 站点,但是在请求后台接口服务的时候还是 Http 请求,导致部署之后,直接在控制台报 This request has been bl ...

  8. 算法金 | 读者问了个关于深度学习卷积神经网络(CNN)核心概念的问题

    ​大侠幸会,在下全网同名[算法金] 0 基础转 AI 上岸,多个算法赛 Top [日更万日,让更多人享受智能乐趣] 读者问了个关于卷积神经网络核心概念的问题,如下, [问]神经元.权重.激活函数.参数 ...

  9. git创建分支 解决git网速太慢

                所谓的分支,就是每个人负责的不同的模块             整个项目有一个主干 master             所有的分支都是 从主干 maser 上 分支而来的   ...

  10. DP Record

    从 2024/5/4 往后开始记录捏. T1. 给你一棵树,定义一个集合的权值为 \(\dfrac{\sum_{x\in S}V_x}{\sum_{x\in S}C_x}\).若一个点 \(\in S ...