EコマースAPI(第2部):認証とミドルウェア
前回の記事では、eコマースAPIのフレームワークを構築しました。今回の目的は、そのフレームワークを安全で、保守しやすく、テストしやすいものにすることです。
ボブのeコマースAPIは、セキュリティ監査の過程で深刻な問題が明らかになりました。認証機能、アクセス制御、リクエストのスロットリングが一切実施されていなかったのです。今こそ、ミドルウェアのパイプラインを強化すべき時です。
1. 学習内容
- JWT認証ミドルウェアの統合
- RBAC(ロールベースのアクセス制御)
- グローバル・ミドルウェア・パイプライン(ロギング + リカバリ + 認証 + RBAC + レート制限)
- SQLデータベースの移行 (golang-migrate)
- 統合テスト(httptest + テスト用データベース)
- ページネーション・ミドルウェア
2. ストーリー:セキュリティ監査報告書
(1) 課題:「どのAPIも認証を必要としないため、誰でも商品を削除できてしまう。」
ボブのMVPが公開された初日、セキュリティチームから監査報告書が送られてきました:
「重大な脆弱性:DELETE /api/v1/products/1 に対して認証が不要です。誰でも製品を削除できてしまいます。中程度の脆弱性:リクエストのレート制限がないため、攻撃者がログイン API に対してブルートフォース攻撃を行う可能性があります。推奨対策:JWT 認証 + RBAC 権限 + レート制限。」
ボブは、以前のバージョンが残したバックドアを調べてみた:
// 悪いコード:認証なし、誰でも注文可能
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
userID := 1 // 定数!すべての注文が user 1 になる
// ビジネスロジック...
}
(2) 学習目標:ミドルウェア・パイプラインを完成させる
Request → Recovery → Logging → CORS → Auth → RBAC → RateLimit → Handler
今週の予定:
- JWT認証ミドルウェア(ハードコーディングされたuserIDに代わるもの)
- RBAC による権限管理(管理者/顧客の役割)
- SQLデータベースの移行(バージョン管理されたテーブルスキーマの管理)
- 統合テスト(httptest + 一時データベース)
- ページネーション・ミドルウェア
3. 完全な実装
▶ サンプル:JWT Toolkit
// 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)
}
▶ サンプル:ミドルウェア・パイプライン
// 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データベースの移行
// 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
}
▶ サンプル:統合テスト
// 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
}
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) リクエストはミドルウェア・パイプラインを通過する
Request
↓ Recovery(panic を捕捉)
↓ Logging(記録 + 所要時間)
↓ CORS(クロスドメイン)
↓ Auth(JWT 検証 → Claims を注入)
↓ RBAC(ロールを確認)
↓ RateLimit(トークンバケット)
↓ Handler(ビジネスロジック)
Response
❓ よくある質問
jwtService.ValidateTokenを呼び出し、context.WithValueを使用してClaimsをリクエストコンテキストに注入します。その後、各ハンドラーはr.Context().Value(middleware.UserClaimsKey)を使用してユーザー情報を取得します。roleフィールドが許可されたロールのリストに含まれているかどうかを確認します。より複雑な実装では、権限マトリックス(各ロールに権限のリストがあり、ミドルウェアが特定の権限をチェックする仕組み)を取り入れることもできます。golang-migrate/migrate—最も一般的に使用されており、複数のデータベースや移行元に対応しています;(2) 手動移行—このレッスンで採用している手法で、小規模なプロジェクトに適しています;(3) pressly/goose—機能が充実しています。大規模なプロジェクトには、golang-migrateをお勧めします。httptest.NewServer または httptest.NewRecorder を使用して HTTP リクエストをシミュレートし、一時データベース(:memory: モードの SQLite)と組み合わせて使用します。テストの手順:データベースの設定 → 依存関係の注入 → ハンドラの作成 → HTTP リクエストの送信 → レスポンスの検証。page および per_page を解析してオフセットを計算します。その後、サービス層で LIMIT ? OFFSET ? をクエリに追加します。ハンドラは、レスポンスに X-Total-Count ヘッダーやメタ情報を追加することができます。このミドルウェアはパラメータの解析のみを担当しており、クエリのロジックを変更することはありません。📖 まとめ
- JWT認証ミドルウェア:トークンの解析、クレームの注入
- RBAC:RequireRole ミドルウェアはロールをチェックします
- ミドルウェアのパイプライン:リカバリ → ロギング → CORS → 認証 → RBAC → レート制限
- SQLマイグレーション:テーブル構造の変更をバージョン管理下で管理する
- 統合テスト:httptest + 一時データベース
- ページネーション:
queryパラメータの分析 +LIMIT/OFFSET
📝 練習問題
-
基本演習(難易度 ⭐):このレッスンで学んだ認証ミドルウェアを、前回のレッスンで作成したeコマースAPIに組み込みます。登録時にJWTトークンを返し、すべての
/api/v1/orders/*エンドポイントに対してBearerトークンの提示を必須とします。トークンが含まれていないリクエストに対しては401エラーを返します。 -
上級問題(難易度 ⭐⭐):ミドルウェアパイプラインの完全な統合テストを実装してください。要件:(1)
httptestと一時的な SQLite データベースを使用すること;(2) 認証フロー(登録 → ログイン → トークンの取得 → トークンを使用して保護されたエンドポイントへのアクセス)をテストすること; (3) 権限拒否のシナリオをテストすること(「customer」が「admin」エンドポイントにアクセスしようとする場合);(4) レート制限のシナリオをテストすること(短期間に大量のリクエストを送信すると429エラーが返される)。 -
課題(難易度:⭐⭐⭐):golang-migrate を使用したデータベースのマイグレーションを実装してください。要件:(1)
golang-migrate/migrateCLI をインストールする;(2) 3つのマイグレーションファイルを作成する(users、products、ordersテーブルを作成するため);(3) Go バイナリ(embedパッケージ)をマイグレーションする; (4)cmd/migrate/main.goがupおよびdownコマンドをサポートしていることを確認する;(5) 移行後のデータベーススキーマを使用して統合テストを実行する。



