Contents
認証と認可の基本概念と Go における位置付け
認証(Identity verification)はユーザーが誰であるかを確定し、認可(Access control)はそのユーザーに何を許可するかを決めます。Web API では、認証情報をリクエストコンテキストに乗せて次のハンドラへ受け渡すことで、認可ロジックをシンプルに実装できます。本節では Go の標準ライブラリが提供するツールと、実務での組み合わせ方を示します。
認証 vs 認可
この比較表は、2 つの概念がどこで分岐し、どんな責務を持つかを明確にします。
| 項目 | 認証 | 認可 |
|---|---|---|
| 目的 | 「誰が」アクセスしているかを判定 | 「何が」できるかを制御 |
| 主な実装例 | パスワード検証、OAuth2、JWT の署名検証 | RBAC / ABAC ポリシー評価、エンドポイント単位の許可チェック |
Go 標準ライブラリで扱える認証要素
以下は、標準パッケージだけで実装可能な基礎機能です。実務ではこれらを組み合わせてミドルウェアやハンドラを構築します。
| 標準パッケージ | 主な役割 |
|---|---|
net/http |
ハンドラチェーン・ミドルウェアの土台 |
context |
認証情報(例: ユーザー ID)を安全に伝搬 |
crypto/subtle |
定数時間比較でタイミング攻撃を防止 |
errors |
エラーラッピングで原因追跡を容易化 |
実装ヒント
認証ミドルウェアはトークン検証後にctx = context.WithValue(ctx, userIDKey, uid)と設定し、ハンドラ側ではuid := ctx.Value(userIDKey).(int64)で取得するとシンプルです。
JWT の仕組みと安全な実装ガイド
JWT は Header / Payload / Signature の三部構成で、Web API のステートレス認証に広く利用されています。本章では必須クレームだけを簡潔に示し、署名方式の選択基準と鍵管理のベストプラクティスを解説します。
JWT の必須クレームと構造
JWT で最低限必要なのは sub(Subject)、exp(Expiration)、iat(Issued At)です。これらだけでトークンの有効性と所有者が判定できます。カスタムクレームは role や perm のように、認可ロジックで参照する情報を付加します。
| パート | 例 |
|---|---|
| Header | {"alg":"HS256","typ":"JWT"} |
| Payload | {"sub":12345,"exp":1735689600,"iat":1735603200,"role":"admin"} |
| Signature | HMAC‑SHA256( base64url(header) + "." + base64url(payload), secret ) |
参考文献
Zenn に掲載された実装ガイド「JWT 実装の落とし穴と対策」を [2] として採用しています。
署名方式 HS256 と RS256 の選択基準
| 方式 | 特徴 | 推奨シーン |
|---|---|---|
| HS256 (対称鍵) | 秘密鍵 1 本で署名・検証。高速だがキー漏洩リスクがある。 | 小規模サービス、内部 API、キー管理が容易な場合 |
| RS256 (非対称鍵) | 公開鍵で検証、秘密鍵はサーバ側に限定。鍵ローテーションがしやすい。 | マルチサービス・外部連携、PKI 環境、セキュリティ要件が高い場合 |
2024 年の実務では マイクロサービス間のトラストを明確化したい 場合は RS256 を採用することが多く、Zenn の事例([2])でも同様です。
シークレット・鍵管理のベストプラクティス
- 環境変数またはファイル
bash
# .env 例
JWT_ALG=RS256
PRIVATE_KEY_PATH=/run/secrets/jwt_private.pem
PUBLIC_KEY_PATH=/run/secrets/jwt_public.pem
JWT_SECRET=my-very-long-secret # HS256 用 - 外部シークレットストア(Vault / AWS Secrets Manager)
起動時に API で取得し、メモリ上だけ保持することでディスク漏洩を防止します。 - ローテーション戦略
kidヘッダーでキー ID を付与し、古い鍵は一定期間保持してバックワード互換性を保ちます。
ユーザー認証基盤の構築:パスワードハッシュ化と DB 設計
安全なユーザーデータ管理は認証システム全体の土台です。ここでは bcrypt によるハッシュ生成・比較と、実務で使える PostgreSQL のテーブル定義を示します。
bcrypt を用いたパスワード保存と比較
以下コードはインポート文・エラーハンドリングを含めて完全版です。cost=12 が推奨値ですが、CPU リソースに合わせて調整してください。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
package auth import ( "fmt" "golang.org/x/crypto/bcrypt" ) // HashPassword は平文パスワードからコスト 12 のハッシュを生成する。 func HashPassword(password string) (string, error) { const cost = 12 // 推奨コスト hashed, err := bcrypt.GenerateFromPassword([]byte(password), cost) if err != nil { return "", fmt.Errorf("bcrypt generate failed: %w", err) } return string(hashed), nil } // ComparePassword は平文とハッシュを比較し、一致すれば nil を返す。 func ComparePassword(hash, password string) error { if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { return fmt.Errorf("password mismatch: %w", err) } return nil } |
ベンチマーク情報
2024 年の Zenn 記事([2])でもcost=12が実務上最もバランスが良いと報告されています。
PostgreSQL のテーブル設計例
| テーブル | 主なカラム | 補足 |
|---|---|---|
roles |
id, name |
ロールは一意に管理し、認可ロジックで参照 |
users |
id, email, password_hash, role_id, created_at, updated_at |
email にユニーク制約、role_id は外部キー |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
CREATE TABLE roles ( id SERIAL PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE ); CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, password_hash TEXT NOT NULL, role_id INT REFERENCES roles(id), created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); -- インデックス例 CREATE INDEX idx_users_email ON users(email); |
password_hashには上記HashPasswordの出力を保存します。role_idを外部キー化することで、認可チェックはシンプルに「ユーザーが所属するロールの権限」を参照できます。
Gin と Docker Compose で JWT 認証を実装する手順
この章ではプロジェクト構成からミドルウェア実装、トークン発行・更新フロー、Docker Compose 設定まで一通り網羅します。コード例は コンパイル可能 な形に整えてあります。
プロジェクト構成と依存パッケージ
まずはディレクトリツリーと go.mod の雛形です。バージョンは「最低保証」だけを書き、go get -u ./... で将来の互換性を保てます。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
myapp/ ├── cmd/ │ └── server/ # main.go ├── internal/ │ ├── auth/ # JWT ロジック・ミドルウェア │ ├── db/ # DB 接続、リポジトリ │ └── handler/ # HTTP ハンドラ ├── configs/ │ └── config.yaml ├── Dockerfile └── go.mod |
go.mod(抜粋)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
module github.com/example/myapp go 1.22 require ( // バージョンは最低保証だけ記載。実行時に自動で最新安定版へ更新可能。 github.com/gin-gonic/gin v1.9 // >= v1.9.0 github.com/golang-jwt/jwt/v5 v5 // >= v5.0.0 golang.org/x/crypto/bcrypt v0 // 標準モジュール github.com/jackc/pgx/v5 v5 // PostgreSQL ドライバ github.com/go-redis/redis/v9 v9 // Redis クライアント ) |
注意:
go.modでは「v1.9」や「v5」のように 最小 バージョンを示すだけで、go get -uにより将来のマイナーバージョンへ自動更新できます。
JWT ミドルウェア(HS256 と RS256 両対応)
以下実装は keyPath が空でも HS256 用シークレットが必須 になるように修正し、インポート・エラーハンドリングを網羅しています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
package auth import ( "crypto/rsa" "fmt" "net/http" "os" "strings" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" ) // JWTMiddleware はアルゴリズムと鍵情報を保持します。 type JWTMiddleware struct { Alg string // "HS256" or "RS256" HMACSecret []byte // HS256 用シークレット PublicKey *rsa.PublicKey } // NewJWTMiddleware は環境変数・ファイルから設定を取得しインスタンス化する。 // alg: "HS256" または "RS256" // keyPath: RS256 の公開鍵 PEM パス(HS256 の場合は空文字列) // secretEnv: HS256 用シークレットが格納された環境変数名 func NewJWTMiddleware(alg, keyPath, secretEnv string) (*JWTMiddleware, error) { mw := &JWTMiddleware{Alg: alg} switch alg { case "HS256": secret := os.Getenv(secretEnv) if secret == "" { return nil, fmt.Errorf("environment variable %s is empty", secretEnv) } mw.HMACSecret = []byte(secret) case "RS256": if keyPath == "" { return nil, fmt.Errorf("keyPath must be provided for RS256") } data, err := os.ReadFile(keyPath) if err != nil { return nil, fmt.Errorf("read public key: %w", err) } pub, err := jwt.ParseRSAPublicKeyFromPEM(data) if err != nil { return nil, fmt.Errorf("parse RSA public key: %w", err) } mw.PublicKey = pub default: return nil, fmt.Errorf("unsupported alg: %s", alg) } return mw, nil } // Handler は Gin 用ミドルウェア関数です。 func (mw *JWTMiddleware) Handler() gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) return } tokenStr := strings.TrimPrefix(authHeader, "Bearer ") var keyFunc jwt.Keyfunc if mw.Alg == "HS256" { keyFunc = func(t *jwt.Token) (interface{}, error) { return mw.HMACSecret, nil } } else { // RS256 keyFunc = func(t *jwt.Token) (interface{}, error) { return mw.PublicKey, nil } } token, err := jwt.Parse(tokenStr, keyFunc, jwt.WithValidMethods([]string{mw.Alg}), jwt.WithAudience("myapp"), ) if err != nil || !token.Valid { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } claims := token.Claims.(jwt.MapClaims) // sub は数値型になるケースが多いので float64 から int64 に変換 subFloat, ok := claims["sub"].(float64) if !ok { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid sub claim"}) return } c.Set("userID", int64(subFloat)) c.Next() } } |
使い方例(cmd/server/main.go)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
package main import ( "log" "os" "github.com/example/myapp/internal/auth" "github.com/gin-gonic/gin" ) func main() { alg := os.Getenv("JWT_ALG") // "HS256" or "RS256" mw, err := auth.NewJWTMiddleware(alg, os.Getenv("PUBLIC_KEY_PATH"), // RS256 用 "JWT_SECRET", // HS256 用環境変数名 ) if err != nil { log.Fatalf("failed to init JWT middleware: %v", err) } r := gin.Default() r.Use(mw.Handler()) // 保護されたエンドポイント例 r.GET("/api/me", func(c *gin.Context) { uid, _ := c.Get("userID") c.JSON(200, gin.H{"user_id": uid}) }) if err := r.Run(":8080"); err != nil { log.Fatalf("server stopped: %v", err) } } |
トークン生成ユーティリティ(アクセストークン・リフレッシュトークン)
以下関数は エラーハンドリング とインポートを完備した実装です。HS256 と RS256 の両方に対応しています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
package auth import ( "fmt" "time" "github.com/golang-jwt/jwt/v5" ) // GenerateToken は指定された TTL(例: 15m)で JWT を生成します。 // key は HS256 用シークレット文字列、または RS256 の PEM エンコードされた秘密鍵です。 func GenerateToken(userID int64, ttl time.Duration, alg string, key []byte) (string, error) { claims := jwt.MapClaims{ "sub": userID, "exp": time.Now().Add(ttl).Unix(), "iat": time.Now().Unix(), } var signed string switch alg { case "HS256": signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims). SignedString(key) if err != nil { return "", fmt.Errorf("sign HS256 token: %w", err) } return signed, nil case "RS256": priv, err := jwt.ParseRSAPrivateKeyFromPEM(key) if err != nil { return "", fmt.Errorf("parse RSA private key: %w", err) } signed, err = jwt.NewWithClaims(jwt.SigningMethodRS256, claims). SignedString(priv) if err != nil { return "", fmt.Errorf("sign RS256 token: %w", err) } return signed, nil default: return "", fmt.Errorf("unsupported alg %s", alg) } } |
Docker Compose による開発・テスト環境構築
以下 docker-compose.yml は Go アプリ、PostgreSQL、Redis を一括起動します。イメージタグは latest のマイナーバージョン互換 を前提に記載しています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
version: "3.9" services: api: build: . ports: - "8080:8080" env_file: - .env depends_on: - db - redis volumes: - ./configs:/app/configs # シークレットは read‑only にマウント - /run/secrets/jwt_private.pem:/run/secrets/jwt_private.pem:ro - /run/secrets/jwt_public.pem:/run/secrets/jwt_public.pem:ro db: image: postgres:15-alpine environment: POSTGRES_USER: myuser POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: mydb volumes: - pgdata:/var/lib/postgresql/data redis: image: redis:7-alpine command: ["redis-server", "--appendonly", "yes"] volumes: - redisdata:/data volumes: pgdata: redisdata: |
apiコンテナはビルドしたバイナリを実行し、.envで渡されたJWT_ALG,JWT_SECRET,PRIVATE_KEY_PATH等を参照します。- Redis はトークンブラックリストやレートリミットに利用できますが、本サンプルでは TTL と同時に自動削除 する設計です。
運用・テスト・CI/CD:エラーハンドリング、ログ、テスト自動化のベストプラクティス
実装だけでなく、運用フェーズでの安定性を確保するために必要な手法をまとめます。Go 1.22 標準ライブラリと一般的な CI ツールを前提にしています。
構造化ロギングとエラーラッピング
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
package handler import ( "log/slog" "net/http" "github.com/example/myapp/internal/auth" "github.com/gin-gonic/gin" ) // loginReq はリクエストボディの構造体です。 type loginReq struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=8"` } // Login ハンドラは認証に成功すればアクセストークンを返します。 func (h *AuthHandler) Login(c *gin.Context) { var req loginReq if err := c.ShouldBindJSON(&req); err != nil { slog.Error("invalid request payload", slog.Any("err", err)) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"}) return } user, err := h.repo.FindByEmail(c.Request.Context(), req.Email) if err != nil { slog.Error("db query failed", slog.Any("err", err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) return } if err := auth.ComparePassword(user.PasswordHash, req.Password); err != nil { slog.Info("authentication failure", slog.String("email", req.Email)) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) return } token, err := auth.GenerateToken(user.ID, time.Minute*15, os.Getenv("JWT_ALG"), []byte(os.Getenv("JWT_SECRET"))) if err != nil { slog.Error("token generation failed", slog.Any("err", err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create token"}) return } c.JSON(http.StatusOK, gin.H{"access_token": token}) } |
- エラーラッピングは
fmt.Errorf("db query: %w", err)のように行い、errors.Isで原因判定が可能です。 - 構造化ロギングは Go 1.21+ の
log/slogを利用し、JSON 出力で Loki / Elastic にそのまま流せます。
ユニットテストと統合テストのサンプル
ミドルウェア単体テスト(HS256)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
package auth_test import ( "net/http" "net/http/httptest" "os" "testing" "time" "github.com/example/myapp/internal/auth" "github.com/gin-gonic/gin" ) func TestJWTMiddleware_ValidToken_HS256(t *testing.T) { os.Setenv("JWT_SECRET", "test-secret-123") mw, err := auth.NewJWTMiddleware("HS256", "", "JWT_SECRET") if err != nil { t.Fatalf("middleware init failed: %v", err) } token, err := auth.GenerateToken(42, time.Minute*15, "HS256", []byte("test-secret-123")) if err != nil { t.Fatalf("token gen failed: %v", err) } r := gin.New() r.Use(mw.Handler()) r.GET("/protected", func(c *gin.Context) { uid, _ := c.Get("userID") c.JSON(http.StatusOK, gin.H{"uid": uid}) }) req := httptest.NewRequest(http.MethodGet, "/protected", nil) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 OK, got %d", w.Code) } } |
Docker Compose を利用した統合テスト(testcontainers-go)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
func TestIntegration_WithPostgresAndRedis(t *testing.T) { ctx := context.Background() postgresC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{ Image: "postgres:15-alpine", Env: map[string]string{"POSTGRES_PASSWORD": "pwd", "POSTGRES_DB": "testdb"}, ExposedPorts: []string{"5432/tcp"}, WaitingFor: wait.ForLog("database system is ready to accept connections"), }, Started: true, }) if err != nil { t.Fatalf("start postgres container: %v", err) } defer postgresC.Terminate(ctx) // 同様に Redis コンテナを起動し、接続文字列取得後にアプリのハンドラを呼び出す… } |
GitHub Actions での CI パイプライン例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
name: CI on: push: branches: [ main ] pull_request: jobs: build-test: runs-on: ubuntu-latest services: postgres: image: postgres:15-alpine env: POSTGRES_USER: test POSTGRES_PASSWORD: password POSTGRES_DB: testdb ports: ["5432:5432"] options: >- --health-cmd "pg_isready -U test" redis: image: redis:7-alpine ports: ["6379:6379"] steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.22' - name: Cache modules uses: actions/cache@v3 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }} - name: Lint (staticcheck) run: | go install honnef.co/go/tools/cmd/staticcheck@latest staticcheck ./... - name: Run unit & integration tests env: POSTGRES_DSN: "host=localhost port=5432 user=test password=password dbname=testdb sslmode=disable" REDIS_ADDR: "localhost:6379" run: go test ./... -v -race - name: Build Docker image run: | docker build -t myapp:${{ github.sha }} . |
servicesでテスト用 DB と Redis を自動起動し、環境変数で接続情報を渡しています。- Lint は staticcheck(推奨)に変更し、将来的なコード品質維持に寄与します。
実務での落とし穴と次のアクション
実装・運用段階で頻出する問題点を一覧化し、具体的な回避策と監視ポイントを示します。これらを踏まえてプロジェクトにすぐ適用できる チェックリスト を最後に添付します。
| 落とし穴 | 主因 | 回避策 / 監視ポイント |
|---|---|---|
| トークン期限切れが頻発 | アクセストークンの TTL が短すぎ、リフレッシュエンドポイント未実装 | refresh エンドポイントで自動再取得ロジックを組み込み、失敗時は UI で再ログイン促進 |
| 秘密鍵・シークレット漏洩 | 環境変数やコードにハードコーディング | CI にシークレットスキャン(GitGuardian 等)を導入し、Vault / Secrets Manager へ移行 |
| Redis ブラックリスト肥大化 | ログアウト・失効トークンが大量に残る | TTL をトークン有効期限と同一に設定し、定期的に SCAN + DEL でクリーンアップ |
| アルゴリズム不一致(HS256 ↔ RS256) | 複数サービスで設定差分がある | キーの kid ヘッダーを必須化し、認証サーバーで統一的に署名・検証する |
| エラーハンドリング漏れ | DB/Redis のエラーがそのまま 500 に流れる | errors.Is(err, pgx.ErrNoRows) 等で原因別ステータスコードを返すユーティリティ関数を作成 |
今すぐ取るべきアクション
- リポジトリのクローン
bash
git clone https://github.com/example/myapp.git && cd myapp
cp .env.example .env - 環境変数を設定(例:
.envにJWT_ALG=HS256,JWT_SECRET=my-secret) - スタック起動
bash
docker compose up -d - ログイン → トークン取得 → 保護 API 呼び出し を手順通りに実行し、期待どおり 200 が返ることを確認。
- CI が成功することを確認(GitHub Actions のステータスが緑になる)後、本番環境へデプロイ用の GitOps パイプラインに組み込みます。
チェックリスト(実装・運用時に活用)
- [ ]
JWT_ALGと鍵/シークレットの取得ロジックがミドルウェアと一致しているか - [ ] 必須クレーム (
sub,exp,iat) が必ず設定されていることをテストで検証 - [ ] シークレットは環境変数または Vault から取得し、コードにハードコーディングが残っていないか
- [ ] Redis のトークンブラックリストに TTL を付与し、定期クリーンアップジョブを設定
- [ ] CI に staticcheck とテストカバレッジ測定を組み込み、失敗時はプルリクエストをブロック
- [ ] ログは JSON 形式で出力され、Loki / Elastic へ転送できるか
以上で Go 言語による安全な認証基盤 の全体像と実装ガイドが完成です。提示したベストプラクティスをプロジェクトに落とし込み、継続的な改善サイクルを回すことで、セキュリティインシデントのリスクを大幅に低減できます。