動態

詳情 返回 返回

PHP轉Go系列 | ThinkPHP與Gin框架之OpenApi授權設計實踐 - 動態 詳情

大家好,我是碼農先森。

我之前待過一個做 ToB 業務的公司,主要是研發以會員為中心的 SaaS 平台,其中涉及的子系統有會員系統、積分系統、營銷系統等。在這個 SaaS 平台中有一個重要的角色「租户」,這個租户可以擁有一個或多個子系統的使用權限,此外租户還可以使用平台所提供的開放 API 「即 OpenApi」來獲取相關係統的數據。有了 OpenApi 租户可以更便捷的與租户自有系統進行打通,提高系統之間數據的傳輸效率。那麼這一次實踐的主要內容是 OpenApi 的授權設計,希望對大家能有所幫助。

我們先梳理一下本次實踐的關鍵步驟:

  • 給每一個租户分配一對 AppKey、AppSecret。
  • 租户通過傳遞 AppKey、AppSecret 參數獲取到平台頒發的 AccessToken。
  • 租户再通過 AccessToken 來換取可以實際調用 API 的 RefreshToken。
  • 這時的 RefreshToken 是具有時效性,目前設置的有效期為 2 個小時。
  • 針對 RefreshToken 還會提供一個刷新時效的接口。
  • 只有 RefreshToken 才有調用業務 API 的真實權限。

有些朋友對 AccessToken 和 RefreshToken 傻傻分不清,疑問重重?我在最開始接觸這個設計的時候也是懵逼的,為啥要搞兩個,一個不也能解決問題嗎?確實搞一個也可以用,但大家如果對接過微信的開放 API 就會發現他們也是有兩個,此外還有很多大的開放平台也是採用類似的設計邏輯,所以存在即合理。

這裏我説一下具體的原因,AccessToken 是基於 AppKey 和 AppSecret 來生成的,而 RefreshToken 是通過 AccessToken 交換得來的。並且 RefreshToken 具備有效性,需要通過一個刷新接口,不定時的刷新 RefreshToken。RefreshToken 的使用是最頻繁的,在每次的業務 API 調用是都需要進行傳輸,傳輸的次數多了那麼 RefreshToken 被劫持的風險就會變大。假設 RefreshToken 真的被泄露,那麼損失也是控制在 2 個小時以內,為了減低損失也還可以調低有效時間。總而言之,網絡的傳輸並不總是能保證安全,AccessToken 在網絡上只需要一次傳輸「即換取 RefreshToken」,而 RefreshToken 需要不斷的在網絡的傳輸「即不斷調用業務 API」,傳輸的次數越少風險就越低,這就是設計兩個 Token 的根本原因。

話不多説,開整!

按照慣例,我們先對整個目錄結構進行梳理。這次的重點邏輯主要是在控制器 controller 的 auth 中實現,包含三個 API 接口一是生成 AccessToken、二是通過 AccessToken 交換 RefreshToken,三是刷新 RefreshToken。中間件 middleware 的 api_auth 是對 RefreshToken 進行解碼驗證,判斷客户端傳遞的 RefreshToken 是否有效。此外,AccessToken 和 RefreshToken 的生成策略都是採用的 JWT 規則。

[manongsen@root php_to_go]$ tree -L 2
.
├── go_openapi
│   ├── app
│   │   ├── controller
│   │   │   ├── auth.go
│   │   │   └── user.go
│   │   ├── middleware
│   │   │   └── api_auth.go
│   │   ├── model
│   │   │   └── tenant.go
│   │   ├── config
│   │   │   └── config.go
│   │   └── route.go
│   ├── go.mod
│   ├── go.sum
│   └── main.go
└── php_openapi
│   ├── app
│   │   ├── controller
│   │   │   ├── Auth.php
│   │   │   └── User.php
│   │   ├── middleware
│   │   │   └── ApiAuth.php
│   │   ├── model
│   │   │   └── Tenant.php
│   │   └── middleware.php
│   ├── composer.json
│   ├── composer.lock
│   ├── config
│   ├── route
│   │   └── app.php
│   ├── think
│   ├── vendor
│   └── .env

ThinkPHP

使用 composer 創建 php_openapi 項目,並且安裝 predis、php-jwt 擴展包。

[manongsen@root ~]$ pwd
/home/manongsen/workspace/php_to_go/php_openapi
[manongsen@root php_openapi]$ composer create-project topthink/think php_openapi
[manongsen@root php_openapi]$ cp .example.env .env

[manongsen@root php_openapi]$ composer require predis/predis
[manongsen@root php_openapi]$ composer require firebase/php-jwt

使用 ThinkPHP 框架提供的命令行工具 php think 創建控制器、中間件、模型文件。

[manongsen@root php_openapi]$ php think make:model Tenant
Model:app\model\Tenant created successfully.

[manongsen@root php_openapi]$ php think make:controller Auth
Controller:app\controller\Auth created successfully.

[manongsen@root php_openapi]$ php think make:controller User
Controller:app\controller\User created successfully.

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

在 route/app.php 文件中定義接口的路由。

<?php
use think\facade\Route;

Route::post('auth/access', 'auth/accessToken');
Route::post('auth/exchange', 'auth/exchangeToken');
Route::post('auth/refresh', 'auth/refreshToken');

// 指定使用 ApiAuth 中間件
Route::group('user', function () {
    Route::get('info', 'user/info');
})->middleware(\app\middleware\ApiAuth::class);

從下面這個控制器 Auth 文件可以看出有 accessToken()、exchangeToken()、refreshToken() 三個方法,分別對應的都是三個 API 接口。這裏會使用 JWT 來生成 Token 令牌,然後統一存儲到 Redis 緩存中。其中 accessToken 的有效時間通常會比 refreshToken 長,但在業務接口的實際調用中使用的是 refreshToken。

<?php

namespace app\controller;

use app\BaseController;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use app\model\Tenant;
use think\facade\Cache;
use think\facade\Env;

class Auth extends BaseController
{
    /**
     * 生成一個 AccessToken
     */
    public function accessToken()
    {
        // 獲取 AppKey 和 AppSecret 參數
        $params = $this->request->param();
        if (!isset($params["app_key"])) {
            return json(["code" => 400, "msg" => "AppKey參數缺失"]);
        }
        $appKey = $params["app_key"];
        if (empty($appKey)) {
            return json(["code" => 400, "msg" => "AppKey參數為空"]);
        }

        if (!isset($params["app_secret"])) {
            return json(["code" => 400, "msg" => "AppSecret參數缺失"]);
        }
        $appSecret = $params["app_secret"];
        if (empty($appSecret)) {
            return json(["code" => 400, "msg" => "AppSecret參數為空"]);
        }

        // 在數據庫中判斷 AppKey 和 AppSecret 是否存在
        $tenant = Tenant::where('app_key', $appKey)->where('app_secret', $appSecret)->find();
        if (is_null($tenant)) {
            return json(["code" => 400, "msg" => "AppKey或AppSecret參數無效"]);
        }

        // 生成一個 AccessToken
        $expiresIn = 7 * 24 * 3600; // 7 天內有效
        $nowTime = time();
        $payload = [
            "iss" => "manongsen", // 簽發者 可以為空
            "aud" => "tenant",    // 面向的用户,可以為空
            "iat" => $nowTime,    // 簽發時間
            "nbf" => $nowTime,    // 生效時間
            "exp" => $nowTime + $expiresIn,  // AccessToken 過期時間
        ];
        $accessToken = JWT::encode($payload, $tenant->app_secret, "HS256");

        $scope = $tenant->scope;
        $data = [
            "access_token" => $accessToken, // 訪問令牌
            "token_type"   => "bearer",     // 令牌類型
            "expires_in"   => $expiresIn,   // 過期時間,單位為秒
            "scope"        => $scope,       // 權限範圍
        ];

        // 存儲到 Redis
        $redis = Cache::store('redis')->handler();
        $redis->set(sprintf("%s.%s", Env::get("ACCESS_TOKEN_PREFIX"), $accessToken), $appKey, $expiresIn);

        return json_encode(["code" => 200, "msg"=>"ok", "data" => $data]);
    }

    /**
     * 通過 AccessToken 換取 RefreshToken
     */
    public function exchangeToken()
    {
        // 獲取 AccessToken 參數
        $params = $this->request->param();
        if (!isset($params["access_token"])) {
            return json(["code" => 400, "msg" => "AccessToken參數缺失"]);
        }
        $accessToken = $params["access_token"];
        if (empty($accessToken)) {
            return json(["code" => 400, "msg" => "AccessToken參數為空"]);
        }

        // 校驗 AccessToken
        $redis = Cache::store('redis')->handler();
        $appKey = $redis->get(sprintf("%s.%s", Env::get("ACCESS_TOKEN_PREFIX"), $accessToken));
        if (empty($appKey)) {
            return json(["code" => 400, "msg" => "AccessToken參數失效"]);
        }

        $tenant = Tenant::where('app_key', $appKey)->find();
        if (is_null($tenant)) {
            return json(["code" => 400, "msg" => "AccessToken參數失效"]);
        }

        $expiresIn = 2 * 3600; // 2 小時內有效
        $nowTime = time();
        $payload = [
            "iss" => "manongsen", // 簽發者, 可以為空
            "aud" => "tenant",    // 面向的用户, 可以為空
            "iat" => $nowTime,    // 簽發時間
            "nbf" => $nowTime,    // 生效時間
            "exp" => $nowTime + $expiresIn,  // RefreshToken 過期時間
        ];
        $refreshToken = JWT::encode($payload, $tenant->app_secret, "HS256");

        // 頒發 RefreshToken
        $data = [
            "refresh_token" => $refreshToken, // 刷新令牌
            "expires_in"    => $expiresIn,    // 過期時間,單位為秒
        ];

        // 存儲到 Redis
        $redis = Cache::store('redis')->handler();
        $redis->set(sprintf("%s.%s", Env::get("REFRESH_TOKEN_PREFIX"), $refreshToken), $appKey, $expiresIn);

        return json_encode(["code" => 200, "msg"=>"ok", "data" => $data]);
    }

    /**
     * 刷新 RefreshToken
     */
    public function refreshToken()
    {
        // 獲取 RefreshToken 參數
        $params = $this->request->param();
        if (!isset($params["refresh_token"])) {
            return json(["code" => 400, "msg" => "RefreshToken參數缺失"]);
        }
        $refreshToken = $params["refresh_token"];
        if (empty($refreshToken)) {
            return json(["code" => 400, "msg" => "RefreshToken參數為空"]);
        }

        // 校驗 RefreshToken
        $redis = Cache::store('redis')->handler();
        $appKey = $redis->get(sprintf("%s.%s", Env::get("REFRESH_TOKEN_PREFIX"), $refreshToken));
        if (empty($appKey)) {
            return json(["code" => 400, "msg" => "RefreshToken參數失效"]);
        }

        $tenant = Tenant::where('app_key', $appKey)->find();
        if (is_null($tenant)) {
            return json(["code" => 400, "msg" => "RefreshToken參數失效"]);
        }

        // 頒發一個新的  RefreshToken
        $expiresIn = 2 * 3600; // 2 小時內有效
        $nowTime = time();
        $payload = [
            "iss" => "manongsen", // 簽發者 可以為空
            "aud" => "tenant",    // 面向的用户,可以為空
            "iat" => $nowTime,    // 簽發時間
            "nbf" => $nowTime,    // 生效時間
            "exp" => $nowTime + $expiresIn,  // RefreshToken 過期時間
        ];
        $newRefreshToken = JWT::encode($payload, $tenant->app_secret, "HS256");

        $data = [
            "refresh_token" => $newRefreshToken, // 新的刷新令牌
            "expires_in"    => $expiresIn,       // 過期時間,單位為秒
        ];

        // 將新的 RefreshToken 存儲到 Redis
        $redis = Cache::store('redis')->handler();
        $redis->set(sprintf("%s.%s", Env::get("REFRESH_TOKEN_PREFIX"), $newRefreshToken), $appKey, $expiresIn);

        // 刪除舊的 RefreshToken
        $redis->del(sprintf("%s.%s", Env::get("REFRESH_TOKEN_PREFIX"), $refreshToken));
        return json_encode(["code" => 200, "msg"=>"ok", "data" => $data]);
    }
}

啓動 php_openapi 服務。

[manongsen@root php_openapi]$ 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_openapi/public
[Wed Jul  3 22:02:16 2024] PHP 8.3.4 Development Server (http://0.0.0.0:8000) started

使用 Postman 工具在 Header 上設置 Authorization 參數「即 RefreshToken」便可以成功的返回數據。

Gin

使用 go mod init 初始化 go_openapi 項目,再使用 go get 安裝相應的第三方依賴庫。

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

[manongsen@root go_openapi]$ go get github.com/gin-gonic/gin
[manongsen@root go_openapi]$ go get gorm.io/gorm
[manongsen@root go_openapi]$ go get github.com/golang-jwt/jwt/v4
[manongsen@root go_openapi]$ go get github.com/go-redis/redis

在 Gin 中沒有類似 php think 的命令行工具,因此需要自行創建 controller、middleware、model 等文件。

在 app/route.go 路由文件中定義接口,和在 ThinkPHP 中的使用差不多並無兩樣。

package app

import (
    "go_openapi/app/controller"
    "go_openapi/app/middleware"

    "github.com/gin-gonic/gin"
)

func InitRoutes(r *gin.Engine) {
    r.POST("/auth/access", controller.AccessToken)
    r.POST("/auth/exchange", controller.ExchangeToken)
    r.POST("/auth/refresh", controller.RefreshToken)

    // 指定使用 ApiAuth 中間件
    user := r.Group("/user/").Use(middleware.ApiAuth())
    user.GET("info", controller.UserInfo)
}

同樣在 Gin 的控制器中也是三個方法對應三個接口。

package controller

import (
    "fmt"
    "go_openapi/app/config"
    "go_openapi/app/model"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/golang-jwt/jwt"
)

// 生成一個 AccessToken
func AccessToken(c *gin.Context) {
    // 獲取 AppKey 和 appSecret 參數
    appKey := c.PostForm("app_key")
    if len(appKey) == 0 {
        c.JSON(http.StatusOK, gin.H{
            "code": 400,
            "msg":  "AppKey參數為空",
        })
        return
    }

    appSecret := c.PostForm("app_secret")
    if len(appSecret) == 0 {
        c.JSON(http.StatusOK, gin.H{
            "code": 400,
            "msg":  "appSecret參數為空",
        })
        return
    }

    // 在數據庫中判斷 AppKey 和 appSecret 是否存在
    var tenant *model.Tenant
    dbRes := config.DemoDB.Model(&model.Tenant{}).
        Where("app_key = ?", appKey).
        Where("app_secret = ?", appSecret).
        First(&tenant)
    if dbRes.Error != nil {
        c.JSON(http.StatusOK, gin.H{
            "code": 500,
            "msg":  "內部服務錯誤",
        })
        return
    }

    // 生成一個 AccessToken
    expiresIn := int64(7 * 24 * 3600) // 7 天內有效
    nowTime := time.Now().Unix()

    jwtToken := jwt.New(jwt.SigningMethodHS256)
    claims := jwtToken.Claims.(jwt.MapClaims)
    claims["iss"] = "manongsen"         // 簽發者 可以為空
    claims["aud"] = "tenant"            // 面向的用户,可以為空
    claims["iat"] = nowTime             // 簽發時間
    claims["nbf"] = nowTime             // 生效時間
    claims["exp"] = nowTime + expiresIn // AccessToken 過期時間
    accessToken, err := jwtToken.SignedString([]byte(tenant.AppSecret))
    if err != nil {
        c.JSON(http.StatusOK, gin.H{
            "code": 500,
            "msg":  "內部服務錯誤",
        })
        return
    }

    scope := tenant.Scope
    data := map[string]interface{}{
        "access_token": accessToken, // 訪問令牌
        "token_type":   "bearer",    // 令牌類型
        "expires_in":   expiresIn,   // 過期時間,單位為秒
        "scope":        scope,       // 權限範圍
    }

    // 存儲 AccessToken 到 Redis
    config.RedisConn.Set(fmt.Sprintf("%s.%s", config.ACCESS_TOKEN_PREFIX, accessToken), tenant.AppKey, time.Second*time.Duration(expiresIn)).Result()
    c.JSON(http.StatusOK, gin.H{
        "code": 200,
        "msg":  "ok",
        "data": data,
    })
}

// 通過 AccessToken 換取 RefreshToken
func ExchangeToken(c *gin.Context) {
    // 獲取 AccessToken 參數
    accessToken := c.PostForm("access_token")
    if len(accessToken) == 0 {
        c.JSON(http.StatusOK, gin.H{
            "code": 400,
            "msg":  "AccessToken參數為空",
        })
        return
    }

    // 校驗 AccessToken
    appKey, err := config.RedisConn.Get(fmt.Sprintf("%s.%s", config.ACCESS_TOKEN_PREFIX, accessToken)).Result()
    if err != nil {
        c.JSON(http.StatusOK, gin.H{
            "code": 500,
            "msg":  "內部服務錯誤",
        })
        return
    }
    if len(appKey) == 0 {
        c.JSON(http.StatusOK, gin.H{
            "code": 400,
            "msg":  "AccessToken參數失效",
        })
        return
    }

    var tenant *model.Tenant
    dbRes := config.DemoDB.Model(&model.Tenant{}).
        Where("app_key = ?", appKey).
        First(&tenant)
    if dbRes.Error != nil {
        c.JSON(http.StatusOK, gin.H{
            "code": 500,
            "msg":  "內部服務錯誤",
        })
        return
    }

    expiresIn := int64(2 * 3600) // 2 小時內有效
    nowTime := time.Now().Unix()

    jwtToken := jwt.New(jwt.SigningMethodHS256)
    claims := jwtToken.Claims.(jwt.MapClaims)
    claims["iss"] = "manongsen"         // 簽發者 可以為空
    claims["aud"] = "tenant"            // 面向的用户,可以為空
    claims["iat"] = nowTime             // 簽發時間
    claims["nbf"] = nowTime             // 生效時間
    claims["exp"] = nowTime + expiresIn // RefreshToken 過期時間
    refreshToken, err := jwtToken.SignedString([]byte(tenant.AppSecret))
    if err != nil {
        c.JSON(http.StatusOK, gin.H{
            "code": 500,
            "msg":  "內部服務錯誤",
        })
        return
    }

    // 頒發 RefreshToken
    data := map[string]interface{}{
        "refresh_token": refreshToken, // 刷新令牌
        "expires_in":    expiresIn,    // 過期時間,單位為秒
    }

    // 存儲到 Redis
    config.RedisConn.Set(fmt.Sprintf("%s.%s", config.REFRESH_TOKEN_PREFIX, refreshToken), appKey, time.Second*time.Duration(expiresIn))
    c.JSON(http.StatusOK, gin.H{
        "code": 200,
        "msg":  "ok",
        "data": data,
    })
}

// 刷新 RefreshToken
func RefreshToken(c *gin.Context) {
    // 獲取 RefreshToken 參數
    refreshToken := c.PostForm("refresh_token")
    if len(refreshToken) == 0 {
        c.JSON(http.StatusOK, gin.H{
            "code": 400,
            "msg":  "RefreshToken參數為空",
        })
        return
    }

    // 校驗 RefreshToken
    appKey, err := config.RedisConn.Get(fmt.Sprintf("%s.%s", config.REFRESH_TOKEN_PREFIX, refreshToken)).Result()
    if err != nil {
        c.JSON(http.StatusOK, gin.H{
            "code": 500,
            "msg":  "內部服務錯誤",
        })
    }
    if len(appKey) == 0 {
        c.JSON(http.StatusOK, gin.H{
            "code": 400,
            "msg":  "AccessToken參數失效",
        })
        return
    }

    var tenant *model.Tenant
    dbRes := config.DemoDB.Model(&model.Tenant{}).
        Where("app_key = ?", appKey).
        First(&tenant)
    if dbRes.Error != nil {
        c.JSON(http.StatusOK, gin.H{
            "code": 500,
            "msg":  "內部服務錯誤",
        })
        return
    }

    // 頒發一個新的  RefreshToken
    expiresIn := int64(2 * 3600) // 2 小時內有效
    nowTime := time.Now().Unix()

    jwtToken := jwt.New(jwt.SigningMethodHS256)
    claims := jwtToken.Claims.(jwt.MapClaims)
    claims["iss"] = "manongsen"         // 簽發者 可以為空
    claims["aud"] = "tenant"            // 面向的用户,可以為空
    claims["iat"] = nowTime             // 簽發時間
    claims["nbf"] = nowTime             // 生效時間
    claims["exp"] = nowTime + expiresIn // RefreshToken 過期時間
    newRefreshToken, err := jwtToken.SignedString([]byte(tenant.AppSecret))
    if err != nil {
        c.JSON(http.StatusOK, gin.H{
            "code": 500,
            "msg":  "內部服務錯誤",
        })
        return
    }

    data := map[string]interface{}{
        "refresh_token": newRefreshToken, // 新的刷新令牌
        "expires_in":    expiresIn,       // 過期時間,單位為秒
    }

    // 將新的 RefreshToken 存儲到 Redis
    config.RedisConn.Set(fmt.Sprintf("%s.%s", config.REFRESH_TOKEN_PREFIX, newRefreshToken), appKey, time.Second*time.Duration(expiresIn))

    // 刪除舊的 RefreshToken
    config.RedisConn.Del(fmt.Sprintf("%s.%s", config.REFRESH_TOKEN_PREFIX, refreshToken))
    c.JSON(http.StatusOK, gin.H{
        "code": 200,
        "msg":  "ok",
        "data": data,
    })
}

啓動 go_openapi 服務。

[manongsen@root go_openapi]$ 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] POST   /auth/access              --> go_openapi/app/controller.AccessToken (3 handlers)
[GIN-debug] POST   /auth/exchange            --> go_openapi/app/controller.ExchangeToken (3 handlers)
[GIN-debug] POST   /auth/refresh             --> go_openapi/app/controller.RefreshToken (3 handlers)
[GIN-debug] GET    /user/info                --> go_openapi/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 工具在 Header 上設置 Authorization 參數「即 RefreshToken」便可以成功的返回數據。

結語

工作中只要接觸過第三方開放平台的都離不開 OpenApi,幾乎各大平台都會有自己的 OpenApi 比如微信、淘寶、京東、抖音等。在 OpenApi 對接的過程中最首要的環節就是授權,獲取到平台的授權 Token 至關重要。對於我們程序員來説,不僅要能對接 OpenApi 獲取到業務數據,還有對其中的授權實現邏輯要有具體的研究,才能通曉其本質做到一通百通。這次我分享的是基於之前公司做 SaaS 平台一些經驗的提取,希望能對大家有所幫助。最好的學習就是實踐,大家可以手動實踐一下,如有需要完整實踐代碼的朋友可在微信公眾號內回覆「1087」獲取對應的代碼。


歡迎關注、分享、點贊、收藏、在看,我是微信公眾號「碼農先森」作者。

user avatar wujingquan 頭像 soroqer 頭像 yujiaao 頭像 yuzhoustayhungry 頭像 zjkal 頭像 yanwushu 頭像 buildyuan 頭像 guangmingleiluodebaomihua 頭像 xiaolanbenlan 頭像 lixingning 頭像 liberhome 頭像 syntaxerror 頭像
點贊 21 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.