写点什么

PHP 转 Go 系列 | ThinkPHP 与 Gin 框架之 OpenApi 授权设计实践

  • 2024-09-26
    福建
  • 本文字数:9871 字

    阅读完需:约 32 分钟

我之前待过一个做 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 TenantModel:app\model\Tenant created successfully.
[manongsen@root php_openapi]$ php think make:controller AuthController:app\controller\Auth created successfully.
[manongsen@root php_openapi]$ php think make:controller UserController:app\controller\User created successfully.
[manongsen@root php_openapi]$ php think make:middleware ApiAuthMiddleware:app\middleware\ApiAuth created successfully.
复制代码


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


<?phpuse 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 runThinkPHP 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")
// 生成一个 AccessTokenfunc 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 换取 RefreshTokenfunc 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, })}
// 刷新 RefreshTokenfunc 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 平台一些经验的提取,希望能对大家有所帮助。最好的学习就是实践,大家可以手动实践一下。


文章转载自:Yxh_blogs

原文链接:https://www.cnblogs.com/yxhblogs/p/18293238

体验地址:http://www.jnpfsoft.com/?from=infoq

用户头像

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
PHP转Go系列 | ThinkPHP与Gin框架之OpenApi授权设计实践_php_快乐非自愿限量之名_InfoQ写作社区