From d3fa4efcecf02bff0452b96deff81f825e5eb21f Mon Sep 17 00:00:00 2001 From: masaya-osuga Date: Sun, 10 May 2026 15:29:09 +0900 Subject: [PATCH 1/3] =?UTF-8?q?shared/auth:=20Firebase=20ID=20Token=20/=20?= =?UTF-8?q?AppCheck=20=E6=A4=9C=E8=A8=BC=E3=83=9F=E3=83=89=E3=83=AB?= =?UTF-8?q?=E3=82=A6=E3=82=A7=E3=82=A2=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全モジュール共通の認証・認可ユーティリティを internal/shared/auth に新設。 Extract で Bearer (失効チェック付き) と AppCheck を best-effort 検証し、 RequireUserUnlessAllowed で default-deny の保護を行う。 カスタムクレーム検証 (RequireAdmin / RequireAnyClaim) はハンドラから呼ぶ 形で提供。Bearer 検証成功時は AppCheck をスキップする。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 6 +++ internal/shared/auth/context.go | 93 ++++++++++++++++++++++++++++++++ internal/shared/auth/errors.go | 23 ++++++++ internal/shared/auth/extract.go | 90 +++++++++++++++++++++++++++++++ internal/shared/auth/firebase.go | 45 ++++++++++++++++ internal/shared/auth/guards.go | 51 ++++++++++++++++++ internal/shared/auth/protect.go | 55 +++++++++++++++++++ 7 files changed, 363 insertions(+) create mode 100644 internal/shared/auth/context.go create mode 100644 internal/shared/auth/errors.go create mode 100644 internal/shared/auth/extract.go create mode 100644 internal/shared/auth/firebase.go create mode 100644 internal/shared/auth/guards.go create mode 100644 internal/shared/auth/protect.go diff --git a/.env.example b/.env.example index 4369548..b300bfb 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,9 @@ DB_IAM_USER=dotto-service@project.iam # 開発時にローカル PostgreSQL に対して migrate:apply:local を実行する場合 DEV_DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/dotto?sslmode=disable + +# Firebase Authentication / App Check (internal/shared/auth) +# Cloud Run 上ではサービスアカウントを ADC として利用するため通常は未設定で良い。 +# ローカル開発時のみサービスアカウント JSON のパスを指定する。 +GOOGLE_APPLICATION_CREDENTIALS= +GOOGLE_CLOUD_PROJECT= diff --git a/internal/shared/auth/context.go b/internal/shared/auth/context.go new file mode 100644 index 0000000..67a2584 --- /dev/null +++ b/internal/shared/auth/context.go @@ -0,0 +1,93 @@ +package auth + +import ( + "context" + + "firebase.google.com/go/v4/auth" + "github.com/gin-gonic/gin" +) + +// Gin / context.Context に検証済みの認証情報を出し入れするためのキー。 +// 衝突を避けるため private な struct を採用する。 +type ( + firebaseTokenKey struct{} + appCheckOKKey struct{} +) + +var ( + tokenCtxKey = firebaseTokenKey{} + appCheckCtxKey = appCheckOKKey{} +) + +// setFirebaseToken は検証済みの Firebase ID Token を Gin / context の両方に格納する。 +// 後段のハンドラからは GetFirebaseToken / UserID を通じて参照する。 +func setFirebaseToken(c *gin.Context, token *auth.Token) { + ctx := context.WithValue(c.Request.Context(), tokenCtxKey, token) + c.Request = c.Request.WithContext(ctx) + c.Set(keyOf(tokenCtxKey), token) +} + +// markAppCheckOK は AppCheck トークンの検証が成功したことを context に記録する。 +func markAppCheckOK(c *gin.Context) { + ctx := context.WithValue(c.Request.Context(), appCheckCtxKey, true) + c.Request = c.Request.WithContext(ctx) + c.Set(keyOf(appCheckCtxKey), true) +} + +// GetFirebaseToken は context.Context から検証済み Firebase Token を取り出す。 +// Extract ミドルウェアを通過していない、もしくは検証に失敗している場合は ok=false。 +func GetFirebaseToken(ctx context.Context) (*auth.Token, bool) { + v := ctx.Value(tokenCtxKey) + if v == nil { + return nil, false + } + token, ok := v.(*auth.Token) + return token, ok +} + +// UserID は検証済みトークンの UID を返す。未認証の場合は空文字列。 +func UserID(ctx context.Context) string { + token, ok := GetFirebaseToken(ctx) + if !ok || token == nil { + return "" + } + return token.UID +} + +// IsAppCheckVerified は AppCheck の検証が成功済みかを返す。 +func IsAppCheckVerified(ctx context.Context) bool { + v := ctx.Value(appCheckCtxKey) + if v == nil { + return false + } + ok, _ := v.(bool) + return ok +} + +// HasClaim は検証済み Firebase Token に対してカスタムクレーム名 claim が +// boolean true で設定されているかを返す。 +func HasClaim(ctx context.Context, claim string) bool { + token, ok := GetFirebaseToken(ctx) + if !ok || token == nil { + return false + } + v, exists := token.Claims[claim] + if !exists { + return false + } + b, ok := v.(bool) + return ok && b +} + +// keyOf は Gin の c.Set 用に struct キーを文字列化する。 +// Gin の context は string キーしか扱わないため、リフレクションを避けて固定文字列に寄せる。 +func keyOf(key any) string { + switch key.(type) { + case firebaseTokenKey: + return "auth.firebaseToken" + case appCheckOKKey: + return "auth.appCheckOK" + default: + return "" + } +} diff --git a/internal/shared/auth/errors.go b/internal/shared/auth/errors.go new file mode 100644 index 0000000..9f35bf0 --- /dev/null +++ b/internal/shared/auth/errors.go @@ -0,0 +1,23 @@ +package auth + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// abortJSON は認証・認可エラーレスポンスを既存 BFF と揃った形式で返す。 +// レスポンスボディは {"error": ""} 固定。 +func abortJSON(c *gin.Context, status int, message string) { + c.AbortWithStatusJSON(status, gin.H{"error": message}) +} + +// abortUnauthorized は 401 を返す。 +func abortUnauthorized(c *gin.Context, message string) { + abortJSON(c, http.StatusUnauthorized, message) +} + +// abortForbidden は 403 を返す。 +func abortForbidden(c *gin.Context, message string) { + abortJSON(c, http.StatusForbidden, message) +} diff --git a/internal/shared/auth/extract.go b/internal/shared/auth/extract.go new file mode 100644 index 0000000..b079880 --- /dev/null +++ b/internal/shared/auth/extract.go @@ -0,0 +1,90 @@ +package auth + +import ( + "context" + "log" + "strings" + + "firebase.google.com/go/v4/auth" + "github.com/gin-gonic/gin" +) + +const ( + authorizationHeader = "Authorization" + bearerPrefix = "Bearer " + appCheckHeader = "X-Firebase-AppCheck" +) + +// Extract は全リクエストに対して best-effort で認証情報を検証する pre middleware。 +// 検証に失敗・トークン欠落の場合でも 401 にはせず、後段の RequireUserUnlessAllowed +// と各ハンドラの gate ヘルパに委ねる。 +// +// 動作概要: +// - Authorization: Bearer ヘッダがあれば失効チェック付きで Firebase ID Token を検証。 +// 成功時は context に *auth.Token を格納する。 +// - Bearer 検証が成功した場合、AppCheck の検証はスキップする +// (ログイン済みユーザは AppCheck を送らなくても叩ける方針)。 +// - Bearer が無い/失敗した場合に限り、X-Firebase-AppCheck ヘッダがあれば検証。 +// 成功時は context に検証済みフラグを記録する。 +func Extract(clients *Clients) gin.HandlerFunc { + return func(c *gin.Context) { + ctx := c.Request.Context() + + bearerOK := tryVerifyBearer(c, ctx, clients.Auth) + if !bearerOK { + tryVerifyAppCheck(c, clients) + } + + c.Next() + } +} + +// tryVerifyBearer は Authorization ヘッダから Bearer トークンを抽出して検証する。 +// 検証失敗時はログを残し false を返す。ヘッダ欠落も false を返す。 +func tryVerifyBearer(c *gin.Context, ctx context.Context, authClient *auth.Client) bool { + header := c.GetHeader(authorizationHeader) + if header == "" { + return false + } + if !strings.HasPrefix(header, bearerPrefix) { + return false + } + idToken := strings.TrimSpace(strings.TrimPrefix(header, bearerPrefix)) + if idToken == "" { + return false + } + + token, err := authClient.VerifyIDTokenAndCheckRevoked(ctx, idToken) + if err != nil { + // 検証失敗は best-effort なのでここでは握りつぶし、後段の gate に判断を委ねる。 + // デバッグ容易性のため log には残しておく(PII を避け UID は出さない)。 + log.Printf("auth: bearer token verification failed: %v", err) + return false + } + + setFirebaseToken(c, token) + return true +} + +// tryVerifyAppCheck は X-Firebase-AppCheck ヘッダがあれば検証する。 +// AppCheck クライアント未注入の場合は何もしない。 +func tryVerifyAppCheck(c *gin.Context, clients *Clients) { + if clients.AppCheck == nil { + return + } + header := c.GetHeader(appCheckHeader) + if header == "" { + return + } + // クライアントによっては "Bearer " 形式で送ってくるため両対応する。 + token := strings.TrimSpace(strings.TrimPrefix(header, bearerPrefix)) + if token == "" { + return + } + + if _, err := clients.AppCheck.VerifyToken(token); err != nil { + log.Printf("auth: app check token verification failed: %v", err) + return + } + markAppCheckOK(c) +} diff --git a/internal/shared/auth/firebase.go b/internal/shared/auth/firebase.go new file mode 100644 index 0000000..9e6588f --- /dev/null +++ b/internal/shared/auth/firebase.go @@ -0,0 +1,45 @@ +// Package auth はモジュラーモノリス全体で共有する認証・認可ユーティリティ。 +// Firebase Authentication / App Check の検証と Gin ハンドラ向けの +// gate ヘルパを提供する。 +package auth + +import ( + "context" + "fmt" + + firebase "firebase.google.com/go/v4" + "firebase.google.com/go/v4/appcheck" + "firebase.google.com/go/v4/auth" +) + +// Clients は Extract / RequireUserUnlessAllowed が依存する Firebase クライアントの束。 +// AppCheck は省略可能(nil でも extract は Bearer のみで動作する)。 +type Clients struct { + Auth *auth.Client + AppCheck *appcheck.Client +} + +// NewClients は Application Default Credentials を用いて Firebase を初期化し、 +// Auth / AppCheck クライアントを返す。Cloud Run 上ではサービスアカウントが +// そのまま ADC として利用される。 +func NewClients(ctx context.Context) (*Clients, error) { + app, err := firebase.NewApp(ctx, nil) + if err != nil { + return nil, fmt.Errorf("firebase.NewApp: %w", err) + } + + authClient, err := app.Auth(ctx) + if err != nil { + return nil, fmt.Errorf("app.Auth: %w", err) + } + + appCheckClient, err := app.AppCheck(ctx) + if err != nil { + return nil, fmt.Errorf("app.AppCheck: %w", err) + } + + return &Clients{ + Auth: authClient, + AppCheck: appCheckClient, + }, nil +} diff --git a/internal/shared/auth/guards.go b/internal/shared/auth/guards.go new file mode 100644 index 0000000..2610726 --- /dev/null +++ b/internal/shared/auth/guards.go @@ -0,0 +1,51 @@ +package auth + +import "github.com/gin-gonic/gin" + +// 既知のカスタムクレーム名。admin-bff-api 側との互換性のためそのまま採用する。 +const ( + ClaimAdmin = "admin" + ClaimDeveloper = "developer" +) + +// RequireAdmin は admin / developer のいずれかのカスタムクレームを持つ Bearer +// での呼び出しのみを許可する。満たさない場合は 403 を返し、ハンドラは +// そのまま return すれば良い (ok=false 時)。 +// +// 前段で RequireUserUnlessAllowed を通過しているため Bearer 検証は済んでいる前提。 +// 想定外に未認証で到達した場合は 401 を返す。 +func RequireAdmin(c *gin.Context) bool { + ctx := c.Request.Context() + + if _, ok := GetFirebaseToken(ctx); !ok { + abortUnauthorized(c, "authentication required") + return false + } + + if HasClaim(ctx, ClaimAdmin) || HasClaim(ctx, ClaimDeveloper) { + return true + } + + abortForbidden(c, "insufficient permissions") + return false +} + +// RequireAnyClaim は指定したカスタムクレームのいずれかが boolean true で +// 設定されていれば true を返す。それ以外は 403 を返し false を返す。 +func RequireAnyClaim(c *gin.Context, claims ...string) bool { + ctx := c.Request.Context() + + if _, ok := GetFirebaseToken(ctx); !ok { + abortUnauthorized(c, "authentication required") + return false + } + + for _, claim := range claims { + if HasClaim(ctx, claim) { + return true + } + } + + abortForbidden(c, "insufficient permissions") + return false +} diff --git a/internal/shared/auth/protect.go b/internal/shared/auth/protect.go new file mode 100644 index 0000000..3aaa8f7 --- /dev/null +++ b/internal/shared/auth/protect.go @@ -0,0 +1,55 @@ +package auth + +import ( + "github.com/gin-gonic/gin" +) + +// AllowList は「ログインしていなくても叩いてよい」エンドポイントの集合。 +// キーは " " 形式。例: "GET /v1/announcements". +// 登録パスは gin.Context.FullPath() の値と一致させる必要がある +// (path パラメータは ":id" のような Gin 形式で書く)。 +type AllowList map[string]struct{} + +// NewAllowList は文字列スライスから AllowList を構築する小ヘルパ。 +func NewAllowList(routes ...string) AllowList { + a := make(AllowList, len(routes)) + for _, r := range routes { + a[r] = struct{}{} + } + return a +} + +// RequireUserUnlessAllowed は default-deny で「ログイン必須」を強制する middleware。 +// allowList に登録された経路では Bearer 認証が無くても通すが、 +// その場合でも AppCheck が検証成功している必要がある(=正規アプリ確認)。 +// allowList に登録されていない経路は Bearer 検証成功が必須。 +// +// 前段に Extract を必ず登録しておくこと。 +func RequireUserUnlessAllowed(allowList AllowList) gin.HandlerFunc { + return func(c *gin.Context) { + ctx := c.Request.Context() + key := c.Request.Method + " " + c.FullPath() + + if _, allowed := allowList[key]; allowed { + // 匿名アクセスを許可する経路。Bearer or AppCheck のいずれかが + // 検証成功していれば通す。 + if _, ok := GetFirebaseToken(ctx); ok { + c.Next() + return + } + if IsAppCheckVerified(ctx) { + c.Next() + return + } + abortUnauthorized(c, "authentication required") + return + } + + // 通常経路。Bearer 検証成功必須。 + if _, ok := GetFirebaseToken(ctx); !ok { + abortUnauthorized(c, "authentication required") + return + } + c.Next() + } +} From 0c5a117113687a3c5a48d61f2bddcdf0fd784758 Mon Sep 17 00:00:00 2001 From: masaya-osuga Date: Sun, 10 May 2026 15:29:15 +0900 Subject: [PATCH 2/3] =?UTF-8?q?academic-api:=20=E5=85=B1=E6=9C=89=20auth?= =?UTF-8?q?=20=E3=83=9F=E3=83=89=E3=83=AB=E3=82=A6=E3=82=A7=E3=82=A2?= =?UTF-8?q?=E3=82=92=E7=B5=84=E3=81=BF=E8=BE=BC=E3=82=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit router に Extract と RequireUserUnlessAllowed を validator の前段で登録。 academic-api は現状すべての endpoint がログイン必須のため allowList は空。 合わせて Firebase 初期化のために signal context を main 冒頭へ移動した。 Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/academic-api/main.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/cmd/academic-api/main.go b/cmd/academic-api/main.go index ba70def..052892f 100644 --- a/cmd/academic-api/main.go +++ b/cmd/academic-api/main.go @@ -17,6 +17,7 @@ import ( "github.com/fun-dotto/server/internal/modules/academic/openapispec" "github.com/fun-dotto/server/internal/modules/academic/repository" "github.com/fun-dotto/server/internal/modules/academic/service" + "github.com/fun-dotto/server/internal/shared/auth" "github.com/fun-dotto/server/internal/shared/db" "github.com/getkin/kin-openapi/openapi3" "github.com/gin-gonic/gin" @@ -38,6 +39,14 @@ func main() { log.Printf("Warning: .env file not found: %v", err) } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + authClients, err := auth.NewClients(ctx) + if err != nil { + log.Fatalf("Failed to initialize Firebase clients: %v", err) + } + conn, err := db.ConnectWithConnectorIAMAuthN() if err != nil { log.Fatalf("Failed to connect to database: %v", err) @@ -60,7 +69,13 @@ func main() { router := gin.Default() + // 匿名アクセスを許可するルートはモジュール側で必要になったら追加する。 + // academic-api は現状すべてログイン必須。 + allowList := auth.NewAllowList() + router.Use(middleware.Timeout(handlerTimeout)) + router.Use(auth.Extract(authClients)) + router.Use(auth.RequireUserUnlessAllowed(allowList)) router.Use(oapimw.OapiRequestValidator(spec)) // Repositories @@ -113,9 +128,6 @@ func main() { IdleTimeout: idleTimeout, } - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - serverErr := make(chan error, 1) go func() { log.Printf("Server starting on %s", srv.Addr) From b235305b52b580c9a5c78cd303474a70841b8ed8 Mon Sep 17 00:00:00 2001 From: masaya-osuga Date: Sun, 10 May 2026 15:58:13 +0900 Subject: [PATCH 3/3] =?UTF-8?q?shared/auth:=20AppCheck=20=E3=82=92?= =?UTF-8?q?=E5=BF=85=E9=A0=88=E5=89=8D=E6=8F=90=E3=81=AB=E6=8F=83=E3=81=88?= =?UTF-8?q?=E3=80=81=E5=88=B0=E9=81=94=E4=B8=8D=E8=83=BD=E3=81=AA=20nil=20?= =?UTF-8?q?=E5=88=86=E5=B2=90=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/shared/auth/extract.go | 4 ---- internal/shared/auth/firebase.go | 3 ++- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/internal/shared/auth/extract.go b/internal/shared/auth/extract.go index b079880..1c8e29a 100644 --- a/internal/shared/auth/extract.go +++ b/internal/shared/auth/extract.go @@ -67,11 +67,7 @@ func tryVerifyBearer(c *gin.Context, ctx context.Context, authClient *auth.Clien } // tryVerifyAppCheck は X-Firebase-AppCheck ヘッダがあれば検証する。 -// AppCheck クライアント未注入の場合は何もしない。 func tryVerifyAppCheck(c *gin.Context, clients *Clients) { - if clients.AppCheck == nil { - return - } header := c.GetHeader(appCheckHeader) if header == "" { return diff --git a/internal/shared/auth/firebase.go b/internal/shared/auth/firebase.go index 9e6588f..9b5cc13 100644 --- a/internal/shared/auth/firebase.go +++ b/internal/shared/auth/firebase.go @@ -13,7 +13,8 @@ import ( ) // Clients は Extract / RequireUserUnlessAllowed が依存する Firebase クライアントの束。 -// AppCheck は省略可能(nil でも extract は Bearer のみで動作する)。 +// Auth / AppCheck の両方が必須。NewClients が初期化に失敗した場合は呼び出し側で +// 起動を中断する想定 (fail-loud)。 type Clients struct { Auth *auth.Client AppCheck *appcheck.Client