Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
# Cloud SQL connection (Cloud SQL IAM 認証)
INSTANCE_CONNECTION_NAME=
DB_NAME=
DB_IAM_USER=

# Migrate job (cmd/migrate-job)
# 同一の INSTANCE_CONNECTION_NAME / DB_NAME / DB_IAM_USER を利用する
# Atlas が読み込む versioned SQL は ./migrations 配下を前提とする

# 開発時にローカル 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=
18 changes: 15 additions & 3 deletions cmd/academic-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
93 changes: 93 additions & 0 deletions internal/shared/auth/context.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
}
23 changes: 23 additions & 0 deletions internal/shared/auth/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package auth

import (
"net/http"

"github.com/gin-gonic/gin"
)

// abortJSON は認証・認可エラーレスポンスを既存 BFF と揃った形式で返す。
// レスポンスボディは {"error": "<message>"} 固定。
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)
}
86 changes: 86 additions & 0 deletions internal/shared/auth/extract.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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 ヘッダがあれば検証する。
func tryVerifyAppCheck(c *gin.Context, clients *Clients) {
header := c.GetHeader(appCheckHeader)
if header == "" {
return
}
// クライアントによっては "Bearer <token>" 形式で送ってくるため両対応する。
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)
}
46 changes: 46 additions & 0 deletions internal/shared/auth/firebase.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// 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 クライアントの束。
// Auth / AppCheck の両方が必須。NewClients が初期化に失敗した場合は呼び出し側で
// 起動を中断する想定 (fail-loud)。
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
}
51 changes: 51 additions & 0 deletions internal/shared/auth/guards.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading