JWT ベースの OpenAPI 認可フロー:ThinkPHP と Gin の実装比較

概要

マルチテナント SaaS プラットフォームでは、外部システムがデータを安全に取得できるよう OpenAPI を公開する。本記事では「アプリ鍵 + シークレット」方式で発行される短期 RefreshToken を用いた認可フローを、PHP(ThinkPHP)と Go(Gin)でそれぞれ実装し、設計とコードを比較する。

認可フローのステップ

  1. テナントごとに一意の ClientID / ClientSecret を事前発行
  2. /oauth/access で Client 情報を POST し、7 日間有効な AccessToken を取得
  3. /oauth/exchange で AccessToken を POST し、2 時間有効な RefreshToken を受け取る
  4. 以降の業務 API は RefreshToken を Authorization: Bearer <token> ヘッダで送信
  5. 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 と照合し、正当なリクエストとして処理される。

タグ: JWT OpenAPI ThinkPHP Gin redis

5月22日 02:50 投稿