EコマースAPI(第2部):認証とミドルウェア

前回の記事では、eコマースAPIのフレームワークを構築しました。今回の目的は、そのフレームワークを安全で、保守しやすく、テストしやすいものにすることです。

ボブのeコマースAPIは、セキュリティ監査の過程で深刻な問題が明らかになりました。認証機能、アクセス制御、リクエストのスロットリングが一切実施されていなかったのです。今こそ、ミドルウェアのパイプラインを強化すべき時です。

1. 学習内容


2. ストーリー:セキュリティ監査報告書

(1) 課題:「どのAPIも認証を必要としないため、誰でも商品を削除できてしまう。」

ボブのMVPが公開された初日、セキュリティチームから監査報告書が送られてきました:

「重大な脆弱性:DELETE /api/v1/products/1 に対して認証が不要です。誰でも製品を削除できてしまいます。中程度の脆弱性:リクエストのレート制限がないため、攻撃者がログイン API に対してブルートフォース攻撃を行う可能性があります。推奨対策:JWT 認証 + RBAC 権限 + レート制限。」

ボブは、以前のバージョンが残したバックドアを調べてみた:

GO
// 悪いコード:認証なし、誰でも注文可能
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    userID := 1  // 定数!すべての注文が user 1 になる
    // ビジネスロジック...
}

(2) 学習目標:ミドルウェア・パイプラインを完成させる

TEXT
Request → Recovery → Logging → CORS → Auth → RBAC → RateLimit → Handler

今週の予定:

  1. JWT認証ミドルウェア(ハードコーディングされたuserIDに代わるもの)
  2. RBAC による権限管理(管理者/顧客の役割)
  3. SQLデータベースの移行(バージョン管理されたテーブルスキーマの管理)
  4. 統合テスト(httptest + 一時データベース)
  5. ページネーション・ミドルウェア

3. 完全な実装

▶ サンプル:JWT Toolkit

GO
// internal/auth/jwt.go
パッケージ auth

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/json"
    "fmt"
    "strings"
    "time"
)

type Claims struct {
    UserID int    `json:"user_id"`
    Role   文字列 `json:"role"`
    Email  文字列 `json:"email"`
}

type TokenPair struct {
    AccessToken  文字列 `json:"access_token"`
    RefreshToken 文字列 `json:"refresh_token"`
}

type jwtService struct {
    secret     []byte
    accessTTL  time.Duration
    refreshTTL time.Duration
}

func NewJWTService(secret 文字列) *jwtService {
    return &jwtService{
        secret:     []byte(secret),
        accessTTL:  15 * time.Minute,
        refreshTTL: 7 * 24 * time.Hour,
    }
}

func (s *jwtService) GenerateToken(claims Claims) (文字列, エラー) {
    header := map[文字列]文字列{"alg": "HS256", "typ": "JWT"}
    headerJSON, _ := json.Marshal(header)

    payload := map[文字列]interface{}{
        "user_id": claims.UserID,
        "role":    claims.Role,
        "email":   claims.Email,
        "exp":     time.Now().Add(s.accessTTL).Unix(),
        "iat":     time.Now().Unix(),
    }
    payloadJSON, _ := json.Marshal(payload)

    headerEnc := base64URLEncode(headerJSON)
    payloadEnc := base64URLEncode(payloadJSON)

    message := headerEnc + "." + payloadEnc
    mac := hmac.New(sha256.New, s.secret)
    mac.Write([]byte(message))
    sig := base64URLEncode(mac.Sum(nil))

    return message + "." + sig, nil
}

func (s *jwtService) ValidateToken(token 文字列) (*Claims, エラー) {
    parts := strings.Split(token, ".")
    if len(parts) != 3 {
        return nil, fmt.Errorf("invalid token")
    }

    message := parts[0] + "." + parts[1]
    mac := hmac.New(sha256.New, s.secret)
    mac.Write([]byte(message))
    expected := base64URLEncode(mac.Sum(nil))

    if !hmac.Equal([]byte(parts[2]), []byte(expected)) {
        return nil, fmt.Errorf("invalid signature")
    }

    payloadJSON, err := base64URLDecode(parts[1])
    if err != nil {
        return nil, err
    }

    var payload struct {
        UserID int     `json:"user_id"`
        Role   文字列  `json:"role"`
        Email  文字列  `json:"email"`
        Exp    float64 `json:"exp"`
    }
    if err := json.Unmarshal(payloadJSON, &payload); err != nil {
        return nil, err
    }

    if time.Now().Unix() > int64(payload.Exp) {
        return nil, fmt.Errorf("token expired")
    }

    return &Claims{
        UserID: payload.UserID,
        Role:   payload.Role,
        Email:  payload.Email,
    }, nil
}

func base64URLEncode(data []byte) 文字列 {
    return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
}

func base64URLDecode(s 文字列) ([]byte, エラー) {
    switch len(s) % 4 {
    case 2:
        s += "=="
    case 3:
        s += "="
    }
    return base64.URLEncoding.DecodeString(s)
}
▶ 試してみよう

▶ サンプル:ミドルウェア・パイプライン

GO
// internal/middleware/middleware.go
package middleware

import (
    "context"
    "encoding/json"
    "log"
    "net/http"
    "strings"
    "sync"
    "time"

    "ecommerce/internal/auth"
)

type contextKey string

const UserClaimsKey contextKey = "user_claims"

// ---------- Middleware の種類 ----------

type Middleware func(http.Handler) http.Handler

func Chain(h http.Handler, mws ...Middleware) http.Handler {
    for i := len(mws) - 1; i >= 0; i-- {
        h = mws[i](h)
    }
    return h
}

// ---------- Recovery ----------

func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("[PANIC] %v", err)
                writeError(w, http.StatusInternalServerError, "internal error")
            }
        }()
        next.ServeHTTP(w, r)
    })
}

// ---------- Logging ----------

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        log.Printf("[%s] %s %s", r.Method, r.URL.Path, r.RemoteAddr)
        next.ServeHTTP(w, r)
        log.Printf("[%s] %s → %v", r.Method, r.URL.Path, time.Since(start))
    })
}

// ---------- CORS ----------

func CORS(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusNoContent)
            return
        }
        next.ServeHTTP(w, r)
    })
}

// ---------- Auth(JWT)----------

func Auth(jwtService *auth.JWTService) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            authHeader := r.Header.Get("Authorization")
            if !strings.HasPrefix(authHeader, "Bearer ") {
                writeError(w, http.StatusUnauthorized, "missing token")
                return
            }

            token := authHeader[7:]
            claims, err := jwtService.ValidateToken(token)
            if err != nil {
                writeError(w, http.StatusUnauthorized, err.Error())
                return
            }

            ctx := context.WithValue(r.Context(), UserClaimsKey, claims)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

// ---------- RBAC ----------

func RequireRole(roles ...string) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            claims, ok := r.Context().Value(UserClaimsKey).(*auth.Claims)
            if !ok {
                writeError(w, http.StatusUnauthorized, "not authenticated")
                return
            }

            for _, role := range roles {
                if claims.Role == role {
                    next.ServeHTTP(w, r)
                    return
                }
            }

            writeError(w, http.StatusForbidden, "insufficient permissions")
        })
    }
}

// ---------- トークンバケットによるレート制御 ----------

type TokenBucket struct {
    mu         sync.Mutex
    tokens     float64
    maxTokens  float64
    refillRate float64
    lastRefill time.Time
}

type IPRateLimiter struct {
    mu      sync.RWMutex
    buckets map[string]*TokenBucket
    rate    float64
    burst   int
}

var globalLimiter = NewIPRateLimiter(100, 200)

func NewIPRateLimiter(rate float64, burst int) *IPRateLimiter {
    return &IPRateLimiter{
        buckets: make(map[string]*TokenBucket),
        rate:    rate,
        burst:   burst,
    }
}

func (rl *IPRateLimiter) getBucket(ip string) *TokenBucket {
    rl.mu.Lock()
    defer rl.mu.Unlock()

    b, ok := rl.buckets[ip]
    if !ok {
        b = &TokenBucket{
            tokens:     float64(rl.burst),
            maxTokens:  float64(rl.burst),
            refillRate: rl.rate,
            lastRefill: time.Now(),
        }
        rl.buckets[ip] = b
    }
    return b
}

func (b *TokenBucket) allow() bool {
    b.mu.Lock()
    defer b.mu.Unlock()

    now := time.Now()
    elapsed := now.Sub(b.lastRefill).Seconds()
    b.tokens = min(b.tokens+elapsed*b.refillRate, b.maxTokens)
    b.lastRefill = now

    if b.tokens >= 1 {
        b.tokens--
        return true
    }
    return false
}

func min(a, b float64) float64 {
    if a < b {
        return a
    }
    return b
}

func RateLimit(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ip := r.RemoteAddr
        if !globalLimiter.getBucket(ip).allow() {
            writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
            return
        }
        next.ServeHTTP(w, r)
    })
}

// ---------- ページネーション ----------

type Pagination struct {
    Page    int `json:"page"`
    PerPage int `json:"per_page"`
    Offset  int `json:"-"`
}

func ParsePagination(r *http.Request) Pagination {
    page := 1
    perPage := 20

    if p := r.URL.Query().Get("page"); p != "" {
        if v, err := parseInt(p); err == nil && v > 0 {
            page = v
        }
    }
    if pp := r.URL.Query().Get("per_page"); pp != "" {
        if v, err := parseInt(pp); err == nil && v > 0 && v <= 100 {
            perPage = v
        }
    }

    return Pagination{
        Page:    page,
        PerPage: perPage,
        Offset:  (page - 1) * perPage,
    }
}

func parseInt(s string) (int, error) {
    var n int
    for _, c := range s {
        if c < '0' || c > '9' {
            return 0, fmt.Errorf("not a number")
        }
        n = n*10 + int(c-'0')
    }
    return n, nil
}

// ---------- ツール ----------

func writeError(w http.ResponseWriter, status int, msg string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
▶ 試してみよう

▶ サンプル:SQLデータベースの移行

GO
// internal/migration/migration.go
package migration

import (
    "database/sql"
    "fmt"
    "log"
)

type Migration struct {
    Version int
    SQL     string
}

var migrations = []Migration{
    {
        Version: 1,
        SQL: `CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            email TEXT UNIQUE NOT NULL,
            password TEXT NOT NULL,
            name TEXT NOT NULL,
            role TEXT NOT NULL DEFAULT 'customer',
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        )`,
    },
    {
        Version: 2,
        SQL: `CREATE TABLE IF NOT EXISTS products (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL,
            description TEXT,
            price REAL NOT NULL,
            stock INTEGER NOT NULL DEFAULT 0,
            category_id INTEGER,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            FOREIGN KEY (category_id) REFERENCES categories(id)
        )`,
    },
    {
        Version: 3,
        SQL: `CREATE TABLE IF NOT EXISTS orders (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL,
            product_id INTEGER NOT NULL,
            quantity INTEGER NOT NULL,
            total_price REAL NOT NULL,
            status TEXT NOT NULL DEFAULT 'pending',
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            FOREIGN KEY (user_id) REFERENCES users(id),
            FOREIGN KEY (product_id) REFERENCES products(id)
        )`,
    },
    {
        Version: 4,
        SQL: `CREATE TABLE IF NOT EXISTS categories (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT UNIQUE NOT NULL,
            description TEXT,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        )`,
    },
}

func RunMigrations(db *sql.DB) error {
    // バージョンテーブルの作成
    _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
        version INTEGER PRIMARY KEY,
        applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )`)
    if err != nil {
        return fmt.Errorf("create migrations table: %w", err)
    }

    for _, m := range migrations {
        // 適用済みか確認
        var exists bool
        err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = ?)", m.Version).Scan(&exists)
        if err != nil {
            return err
        }
        if exists {
            continue
        }

        // マイグレーションを適用
        if _, err := db.Exec(m.SQL); err != nil {
            return fmt.Errorf("migration %d: %w", m.Version, err)
        }

        // バージョンを記録
        if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil {
            return err
        }

        log.Printf("[移行] バージョン %d 適用済み", m.Version)
    }

    return nil
}
▶ 試してみよう

▶ サンプル:統合テスト

GO
// internal/handler/integration_test.go
パッケージ handler

import (
    "bytes"
    "データベース/sql"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "ecommerce/internal/auth"
    "ecommerce/internal/ミドルウェア"
    "ecommerce/internal/リポジトリ"
    "ecommerce/internal/service"

    _ "github.com/mattn/go-sqlite3"
)

func setupTestDB(t *testing.T) *sql.DB {
    db, err := sql.Open("sqlite3", ":memory:")
    if err != nil {
        t.Fatal(err)
    }

    // テーブルの初期化
    _, err = db.Exec(`
        CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, name TEXT NOT NULL, role TEXT DEFAULT 'customer');
        CREATE TABLE products (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, 説明 TEXT, price REAL NOT NULL, stock INTEGER DEFAULT 0);
        CREATE TABLE orders (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, product_id INTEGER NOT NULL, quantity INTEGER NOT NULL, total_price REAL NOT NULL, ステータス TEXT DEFAULT 'pending');
    `)
    if err != nil {
        t.Fatal(err)
    }

    return db
}

func setupTestApp(db *sql.DB) http.Handler {
    userRepo := リポジトリ.NewUserRepository(db)
    productRepo := リポジトリ.NewProductRepository(db)
    orderRepo := リポジトリ.NewOrderRepository(db)

    userSvc := service.NewUserService(userRepo)
    productSvc := service.NewProductService(productRepo)
    orderSvc := service.NewOrderService(orderRepo, productRepo, userRepo)

    userHandler := NewUserHandler(userSvc)
    productHandler := NewProductHandler(productSvc)
    orderHandler := NewOrderHandler(orderSvc)

    mux := http.NewServeMux()
    userHandler.Register(mux)
    productHandler.Register(mux)
    orderHandler.Register(mux)

    // アプリケーション・ミドルウェア
    return ミドルウェア.Chain(mux,
        ミドルウェア.Recovery,
        ミドルウェア.Logging,
        ミドルウェア.CORS,
    )
}

func TestRegisterAndLogin(t *testing.T) {
    db := setupTestDB(t)
    defer db.Close()
    app := setupTestApp(db)

    // 1. 登録
    registerBody := `{"email":"test@example.com","password":"password123","name":"Test User"}`
    req := httptest.NewRequest("POST", "/api/v1/register", bytes.NewBufferString(registerBody))
    req.Header.Set("Content-Type", "application/json")
    resp := httptest.NewRecorder()
    app.ServeHTTP(resp, req)

    if resp.Code != http.StatusCreated {
        t.Fatalf("expected 201, got %d", resp.Code)
    }

    // 2. ログイン
    loginBody := `{"email":"test@example.com","password":"password123"}`
    req = httptest.NewRequest("POST", "/api/v1/login", bytes.NewBufferString(loginBody))
    req.Header.Set("Content-Type", "application/json")
    resp = httptest.NewRecorder()
    app.ServeHTTP(resp, req)

    if resp.Code != http.StatusOK {
        t.Fatalf("expected 200, got %d", resp.Code)
    }

    var result map[文字列]interface{}
    json.NewDecoder(resp.Body).Decode(&result)
    data := result["data"].(map[文字列]interface{})
    if data["email"] != "test@example.com" {
        t.Errorf("expected test@example.com, got %v", data["email"])
    }
}

func TestCreateOrder_RequiresAuth(t *testing.T) {
    db := setupTestDB(t)
    defer db.Close()
    app := setupTestApp(db)

    // Token がないリクエストは 401 を返すべき
    orderBody := `{"product_id":1,"quantity":1}`
    req := httptest.NewRequest("POST", "/api/v1/orders", bytes.NewBufferString(orderBody))
    req.Header.Set("Content-Type", "application/json")
    resp := httptest.NewRecorder()
    // 注意:現在のバージョンには Auth ミドルウェアがない、次のレッスンで追加予定
    // このテストは失敗する、統合テスト環境に Auth ミドルウェアを追加する必要がある
    _ = resp
    _ = app
}
▶ 試してみよう
100%
sequenceDiagram
    participant Client
    participant MW as Middlewareパイプライン
    participant AuthS as Auth Service
    participant H as Handler

    Client->>MW: POST /api/v1/orders (Bearer token)
    MW->>MW: Recovery / Logging / CORS
    MW->>AuthS: Auth ミドルウェアが JWT を検証
    AuthS-->>MW: Claims {user_id, role}
    MW->>MW: RBAC が customer ロールを確認
    MW->>MW: RateLimit を確認
    MW->>H: 通過!user_id を Context に注入
    H->>H: 注文を作成(user_id を使用)
    H-->>MW: 201 Created
    MW-->>Client: JSON レスポンス

(4) リクエストはミドルウェア・パイプラインを通過する

TEXT
Request
  ↓ Recovery(panic を捕捉)
  ↓ Logging(記録 + 所要時間)
  ↓ CORS(クロスドメイン)
  ↓ Auth(JWT 検証 → Claims を注入)
  ↓ RBAC(ロールを確認)
  ↓ RateLimit(トークンバケット)
  ↓ Handler(ビジネスロジック)
Response
🔥 よくある間違い: AuthミドルウェアはRBACよりも前に配置する必要があります。 まず、トークンを検証してユーザー情報を取得し、その後で権限を確認してください。トークンの検証前に権限を確認してしまうと、認証されていないユーザーが認証エラーではなく権限エラーを引き起こす可能性があり、これはセキュリティ上のリスクとなります。


❓ よくある質問

Q JWTはミドルウェアにどのように統合されていますか?
A Authミドルウェアは、ヘッダー内のBearerトークンを解析し、jwtService.ValidateTokenを呼び出し、context.WithValueを使用してClaimsをリクエストコンテキストに注入します。その後、各ハンドラーはr.Context().Value(middleware.UserClaimsKey)を使用してユーザー情報を取得します。
Q RBACの中間実装について?
A Authミドルウェアの後に、RequireRoleミドルウェアが、Claims内のroleフィールドが許可されたロールのリストに含まれているかどうかを確認します。より複雑な実装では、権限マトリックス(各ロールに権限のリストがあり、ミドルウェアが特定の権限をチェックする仕組み)を取り入れることもできます。
Q SQL移行ツールはどのように選べばよいですか?
A (1) golang-migrate/migrate—最も一般的に使用されており、複数のデータベースや移行元に対応しています;(2) 手動移行—このレッスンで採用している手法で、小規模なプロジェクトに適しています;(3) pressly/goose—機能が充実しています。大規模なプロジェクトには、golang-migrateをお勧めします。
Q 統合テストはどのように書けばよいですか?
A httptest.NewServer または httptest.NewRecorder を使用して HTTP リクエストをシミュレートし、一時データベース(:memory: モードの SQLite)と組み合わせて使用します。テストの手順:データベースの設定 → 依存関係の注入 → ハンドラの作成 → HTTP リクエストの送信 → レスポンスの検証。
Q ページネーションミドルウェアはどのように実装されていますか?
A クエリパラメータ page および per_page を解析してオフセットを計算します。その後、サービス層で LIMIT ? OFFSET ? をクエリに追加します。ハンドラは、レスポンスに X-Total-Count ヘッダーやメタ情報を追加することができます。このミドルウェアはパラメータの解析のみを担当しており、クエリのロジックを変更することはありません。

📖 まとめ


📝 練習問題

  1. 基本演習(難易度 ⭐):このレッスンで学んだ認証ミドルウェアを、前回のレッスンで作成したeコマースAPIに組み込みます。登録時にJWTトークンを返し、すべての/api/v1/orders/*エンドポイントに対してBearerトークンの提示を必須とします。トークンが含まれていないリクエストに対しては401エラーを返します。

  2. 上級問題(難易度 ⭐⭐)ミドルウェアパイプラインの完全な統合テストを実装してください。要件:(1) httptest と一時的な SQLite データベースを使用すること;(2) 認証フロー(登録 → ログイン → トークンの取得 → トークンを使用して保護されたエンドポイントへのアクセス)をテストすること; (3) 権限拒否のシナリオをテストすること(「customer」が「admin」エンドポイントにアクセスしようとする場合);(4) レート制限のシナリオをテストすること(短期間に大量のリクエストを送信すると429エラーが返される)。

  3. 課題(難易度:⭐⭐⭐)golang-migrate を使用したデータベースのマイグレーションを実装してください。要件:(1) golang-migrate/migrate CLI をインストールする;(2) 3つのマイグレーションファイルを作成する(usersproductsorders テーブルを作成するため);(3) Go バイナリ(embed パッケージ)をマイグレーションする; (4) cmd/migrate/main.goupおよびdownコマンドをサポートしていることを確認する;(5) 移行後のデータベーススキーマを使用して統合テストを実行する。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%