概要
マルチテナント SaaS プラットフォームでは、外部システムがデータを安全に取得できるよう OpenAPI を公開する。本記事では「アプリ鍵 + シークレット」方式で発行される短期 RefreshToken を用いた認可フローを、PHP(ThinkPHP)と Go(Gin)でそれぞれ実装し、設計とコードを比較する。
認可フローのステップ
- テナントごとに一意の
ClientID/ClientSecretを事前発行 /oauth/accessで Client 情報を POST し、7 日間有効な AccessToken を取得/oauth/exchangeで AccessToken を POST し、2 時間有効な RefreshToken を受け取る- 以降の業務 API は RefreshToken を
Authorization: Bearer <token>ヘッダで送信 - RefreshToken の有効期限が近づいたら
/oauth/refreshで更新
AccessToken はネットワーク上を 1 回しか通らないため、万が一 RefreshToken が漏洩しても被害は 2 時間以内に留まる。
ディレクトリ構成
.
├── php_openapi
│ ├── app
│ │ ├── controller/OAuth.php
│ │ ├── middleware/ApiAuth.php
│ │ └── model/Tenant.php
│ └── route/app.php
└── go_openapi
├── app
│ ├── controller/oauth.go
│ ├── middleware/api_auth.go
│ └── model/tenant.go
└── app/route.go
ThinkPHP 実装
1. インストールとスケルトン生成
composer create-project topthink/think php_openapi
composer require predis/predis firebase/php-jwt
php think make:model Tenant
php think make:controller OAuth
php think make:middleware ApiAuth
2. ルーティング (route/app.php)
<?php
use think\facade\Route;
Route::post('oauth/access', 'OAuth/access');
Route::post('oauth/exchange', 'OAuth/exchange');
Route::post('oauth/refresh', 'OAuth/refresh');
Route::group('v1', function () {
Route::get('profile', 'User/profile');
})->middleware(\app\middleware\ApiAuth::class);
3. OAuth コントローラ (app/controller/OAuth.php)
<?php
namespace app\controller;
use Firebase\JWT\JWT;
use think\facade\Cache;
use think\facade\Env;
use app\model\Tenant;
class OAuth
{
public function access()
{
[$id, $secret] = [$this->request->post('client_id'), $this->request->post('client_secret')];
if (!$id || !$secret) return json(['code' => 400, 'msg' => 'invalid params']);
$tenant = Tenant::where(['client_id' => $id, 'client_secret' => $secret])->find();
if (!$tenant) return json(['code' => 401, 'msg' => 'unauthorized']);
$exp = 7 * 86400;
$payload = [
'iss' => 'saas',
'sub' => $id,
'exp' => time() + $exp
];
$token = JWT::encode($payload, $secret, 'HS256');
Cache::store('redis')->set("acc:$token", $id, $exp);
return json([
'access_token' => $token,
'token_type' => 'Bearer',
'expires_in' => $exp
]);
}
public function exchange()
{
$acc = $this->request->post('access_token');
if (!$acc) return json(['code' => 400, 'msg' => 'access_token required']);
$id = Cache::store('redis')->get("acc:$acc");
if (!$id) return json(['code' => 401, 'msg' => 'invalid access_token']);
$tenant = Tenant::where('client_id', $id)->find();
$exp = 2 * 3600;
$payload = [
'iss' => 'saas',
'sub' => $id,
'exp' => time() + $exp
];
$refresh = JWT::encode($payload, $tenant->client_secret, 'HS256');
Cache::store('redis')->set("ref:$refresh", $id, $exp);
return json([
'refresh_token' => $refresh,
'expires_in' => $exp
]);
}
public function refresh()
{
$old = $this->request->post('refresh_token');
if (!$old) return json(['code' => 400, 'msg' => 'refresh_token required']);
$id = Cache::store('redis')->get("ref:$old");
if (!$id) return json(['code' => 401, 'msg' => 'invalid refresh_token']);
$tenant = Tenant::where('client_id', $id)->find();
$exp = 2 * 3600;
$payload = [
'iss' => 'saas',
'sub' => $id,
'exp' => time() + $exp
];
$new = JWT::encode($payload, $tenant->client_secret, 'HS256');
Cache::store('redis')
->set("ref:$new", $id, $exp)
->del("ref:$old");
return json([
'refresh_token' => $new,
'expires_in' => $exp
]);
}
}
Gin 実装
1. モジュール初期化
go mod init go_openapi
go get github.com/gin-gonic/gin
go get gorm.io/gorm
go get github.com/golang-jwt/jwt/v4
go get github.com/redis/go-redis/v9
2. ルーティング (app/route.go)
package app
import (
"go_openapi/app/controller"
"go_openapi/app/middleware"
"github.com/gin-gonic/gin"
)
func InitRoutes(r *gin.Engine) {
r.POST("/oauth/access", controller.Access)
r.POST("/oauth/exchange", controller.Exchange)
r.POST("/oauth/refresh", controller.Refresh)
api := r.Group("/v1")
api.Use(middleware.ApiAuth())
api.GET("/profile", controller.Profile)
}
3. OAuth コントローラ (app/controller/oauth.go)
package controller
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v4"
"go_openapi/app/config"
"go_openapi/app/model"
)
func Access(c *gin.Context) {
id := c.PostForm("client_id")
secret := c.PostForm("client_secret")
if id == "" || secret == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid params"})
return
}
var t model.Tenant
if err := config.DB.Where("client_id = ? AND client_secret = ?", id, secret).First(&t).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
exp := time.Now().Add(7 * 24 * time.Hour).Unix()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": "saas",
"sub": id,
"exp": exp,
})
acc, _ := token.SignedString([]byte(secret))
config.RDB.Set(c, "acc:"+acc, id, 7*24*time.Hour)
c.JSON(http.StatusOK, gin.H{
"access_token": acc,
"token_type": "Bearer",
"expires_in": 7 * 24 * 3600,
})
}
func Exchange(c *gin.Context) {
acc := c.PostForm("access_token")
if acc == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "access_token required"})
return
}
id, err := config.RDB.Get(c, "acc:"+acc).Result()
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid access_token"})
return
}
var t model.Tenant
config.DB.Where("client_id = ?", id).First(&t)
exp := time.Now().Add(2 * time.Hour).Unix()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": "saas",
"sub": id,
"exp": exp,
})
ref, _ := token.SignedString([]byte(t.ClientSecret))
config.RDB.Set(c, "ref:"+ref, id, 2*time.Hour)
c.JSON(http.StatusOK, gin.H{
"refresh_token": ref,
"expires_in": 2 * 3600,
})
}
func Refresh(c *gin.Context) {
old := c.PostForm("refresh_token")
if old == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "refresh_token required"})
return
}
id, err := config.RDB.Get(c, "ref:"+old).Result()
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh_token"})
return
}
var t model.Tenant
config.DB.Where("client_id = ?", id).First(&t)
exp := time.Now().Add(2 * time.Hour).Unix()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": "saas",
"sub": id,
"exp": exp,
})
newRef, _ := token.SignedString([]byte(t.ClientSecret))
config.RDB.Set(c, "ref:"+newRef, id, 2*time.Hour)
config.RDB.Del(c, "ref:"+old)
c.JSON(http.StatusOK, gin.H{
"refresh_token": newRef,
"expires_in": 2 * 3600,
})
}
動作確認
両実装とも Authorization: Bearer <refresh_token> ヘッダを付与して GET /v1/profile を叩けば、Redis に保存されたテナント ID と照合し、正当なリクエストとして処理される。