大家好,我是碼農先森。
我之前待過一個做 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」獲取對應的程式碼。
歡迎關注、分享、點贊、收藏、在看,我是微信公眾號「碼農先森」作者。