package api import ( "log/slog" "net/http" "strings" "time" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/google/uuid" "visiona-backend/internal/auth" "visiona-backend/internal/usersession" ) // gin context key 常數 — 集中管理避免拼寫錯誤。 const ( // ctxKeyUserContext 是儲存 *auth.UserContext 的 gin context key。 ctxKeyUserContext = "auth.userContext" // ctxKeyRequestID 是請求追蹤 ID 的 gin context key(同時也會寫到 response header)。 ctxKeyRequestID = "request.id" // ctxKeyUserSession 是儲存 OIDC 模式下 *usersession.Session 的 gin context key。 // 由 AuthMiddleware 設定;handler 可選用以避免再次 lookup。 ctxKeyUserSession = "auth.userSession" ) // RequestIDMiddleware 給每個 request 產生 UUID 作為追蹤 ID。 // // 行為: // - 若 request 帶 X-Request-ID header,直接沿用(讓上游 LB / mesh 串起來) // - 否則產生新的 UUID v4 // - 寫到 gin.Context(給 logger / handler 用)+ response header func RequestIDMiddleware() gin.HandlerFunc { return func(c *gin.Context) { rid := c.GetHeader("X-Request-ID") if rid == "" { rid = uuid.NewString() } c.Set(ctxKeyRequestID, rid) c.Writer.Header().Set("X-Request-ID", rid) c.Next() } } // LoggerMiddleware 用結構化 slog 記錄每個請求的關鍵欄位。 // // 對齊 backend/CLAUDE.md §6.1 的結構化日誌要求: // - timestamp、level、service:由 logger 預設帶 // - request_id、http_method、http_path、http_status、duration_ms // - user_id:若 AuthMiddleware 已執行則一併帶上 // // logger 為 nil 時 fallback 到 slog.Default — 對 test fixture 友善。 func LoggerMiddleware(logger *slog.Logger) gin.HandlerFunc { if logger == nil { logger = slog.Default() } return func(c *gin.Context) { start := time.Now() path := c.Request.URL.Path c.Next() duration := time.Since(start) // 取出 request ID / user ID(若有) rid, _ := c.Get(ctxKeyRequestID) var userID string if uc, ok := UserContextFrom(c); ok { userID = uc.UserID } // 根據 status code 決定 log level。 // 501 NOT_IMPLEMENTED 是「刻意設計」的回應,不應該觸發 error 告警 → 降為 INFO。 status := c.Writer.Status() level := slog.LevelInfo switch { case status == http.StatusNotImplemented: level = slog.LevelInfo case status >= 500: level = slog.LevelError case status >= 400: level = slog.LevelWarn } logger.LogAttrs(c.Request.Context(), level, "http request", slog.String("request_id", asString(rid)), slog.String("user_id", userID), slog.String("action", "http.request"), slog.String("http_method", c.Request.Method), slog.String("http_path", path), slog.Int("http_status", status), slog.Int64("duration_ms", duration.Milliseconds()), ) } } // RecoveryMiddleware 攔截 handler panic,記錄並回 500。 // // 不直接用 gin.Recovery() 是因為要走我們統一的 JSON error 格式。 // logger 為 nil 時 fallback 到 slog.Default — 這條路徑在測試環境會被觸發。 func RecoveryMiddleware(logger *slog.Logger) gin.HandlerFunc { if logger == nil { logger = slog.Default() } return func(c *gin.Context) { defer func() { if rec := recover(); rec != nil { logger.Error("panic recovered", "error", rec, "path", c.Request.URL.Path, "method", c.Request.Method) WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "internal server error", nil) c.Abort() } }() c.Next() } } // CORSMiddleware 用 gin-contrib/cors 設定 CORS 規則。 // // 預設允許 http://localhost:3000(前端 dev server); // allowedOrigins 為空則 fallback 到該預設值。生產環境應由 caller 注入正式網域。 // // 允許的 method / header 對齊一般 REST API 需求;不開放 wildcard '*' Origin 以 // 避免「攜帶 cookie 的 cross-origin 請求」被瀏覽器擋下。 func CORSMiddleware(allowedOrigins []string) gin.HandlerFunc { if len(allowedOrigins) == 0 { allowedOrigins = []string{"http://localhost:3000"} } return cors.New(cors.Config{ AllowOrigins: allowedOrigins, AllowMethods: []string{ http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions, }, AllowHeaders: []string{ "Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID", "X-Idempotency-Key", }, ExposeHeaders: []string{ "X-Request-ID", "X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset", }, AllowCredentials: true, MaxAge: 12 * time.Hour, }) } // AuthMiddleware 從 cookie 解析 OIDC session 並把 UserContext 放進 gin.Context。 // // OB5(2026-04-26)起,OIDC 是唯一認證路徑: // - 從 cookie 讀 session ID → SessionManager.GetSession // - 必須是「已登入 session」(UserID 非空;空代表只是 OIDC pending session) // - 注入 UserContext + Session 到 gin.Context // // 任何失敗一律 401 UNAUTHORIZED,由 frontend 處理 redirect 到 /api/auth/login。 // // SessionManager 已由 NewRouter 的 validate() 確保非 nil(缺則啟動時就 panic)— // 因此此 middleware 不需 nil check。 func AuthMiddleware(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { sess, err := deps.SessionManager.GetSession(c.Request.Context(), c.Request) if err != nil { // no session / cookie 過期 / store 找不到 → 一律 401 WriteError(c, http.StatusUnauthorized, ErrCodeUnauthorized, "no_session", nil) c.Abort() return } // 區分「pending session」(OIDC dance 進行中、UserID 還是空)vs「已登入 session」。 // // ⚠️ 安全臨界檢查:此判斷是 ADR-012「合一 cookie 設計」的核心防線,**不可拿掉、不可放寬**。 // // 背景:oidc-tdd.md §4.5 原設計為 pending 與 logged-in 各一個 cookie;OB2 / OB4 為了 // 簡化實作合一(兩種狀態共用 visiona_session cookie + 同一個 store record,由 UserID 是否 // 為空判斷階段)。合一設計的安全前提是「protected endpoint 在 middleware 層強制檢查 // UserID 非空」——若刪掉這個檢查,攻擊者拿到 pending session cookie(自己跑 /api/auth/login // 即得)就能直接訪問所有 protected endpoint,雖然 UserID 為空看不到資料,但側通道風險 // 與後續 handler 的健壯性都成問題。 // // 配套防護:login callback 完成時呼叫 SessionManager.RotateSessionID 換 ID(Fix-A1), // pending 階段的舊 cookie 從此失效,搭配此處檢查雙保險。 // // 詳見:.autoflow/04-architecture/adr/adr-012-pending-session-shared-cookie.md if sess.UserID == "" { WriteError(c, http.StatusUnauthorized, ErrCodeUnauthorized, "session_not_authenticated", nil) c.Abort() return } c.Set(ctxKeyUserContext, &auth.UserContext{ UserID: sess.UserID, Email: sess.Email, // Roles / OrgID 雛形未實作(Member Center 不回傳) }) // 把 session 也放進 context,handler(如 /auth/me)可避免再次 lookup。 c.Set(ctxKeyUserSession, sess) c.Next() } } // UserContextFrom 從 gin.Context 取出 *auth.UserContext。 // 第二個 return 為 false 表示 AuthMiddleware 未執行或解析失敗。 func UserContextFrom(c *gin.Context) (*auth.UserContext, bool) { v, exists := c.Get(ctxKeyUserContext) if !exists { return nil, false } uc, ok := v.(*auth.UserContext) if !ok { return nil, false } return uc, true } // UserSessionFrom 從 gin.Context 取出 OIDC 模式下的 *usersession.Session。 // 第二個 return 為 false 表示 AuthMiddleware 未執行或 session 缺失。 // // 用途:handler(例如 /api/auth/me)想拿 Email / Name 等 session 額外欄位時, // 可以避免重複 cookie + store lookup(已由 middleware 完成)。 func UserSessionFrom(c *gin.Context) (*usersession.Session, bool) { v, exists := c.Get(ctxKeyUserSession) if !exists { return nil, false } sess, ok := v.(*usersession.Session) if !ok { return nil, false } return sess, true } // RequestIDFrom 從 gin.Context 取出本次請求的 request ID。 func RequestIDFrom(c *gin.Context) string { v, exists := c.Get(ctxKeyRequestID) if !exists { return "" } return asString(v) } // ErrorMiddleware 把 handler 透過 c.Error() 推上來的錯誤統一處理。 // // 目前的實作:若 handler 已經寫了 response(c.Writer.Written()),就不覆蓋; // 否則寫一個泛用的 500。後續 B5 可以擴充對特定 error type 做客製化轉換。 func ErrorMiddleware() gin.HandlerFunc { return func(c *gin.Context) { c.Next() if len(c.Errors) == 0 { return } if c.Writer.Written() { return } // 取最後一個 error 作為主要訊息(gin 慣例) last := c.Errors.Last() WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, last.Error(), nil) } } // asString 把 any 安全轉成 string(gin context 取值常用)。 func asString(v any) string { if v == nil { return "" } if s, ok := v.(string); ok { return s } return "" } // StripBearerPrefix 從 Authorization header 取出 token;不是 Bearer 開頭則回原值。 // // OIDC 路徑下 visionA-backend 不再透過 Authorization header 接 token,但保留此 helper // 給未來 service-to-service auth(例:Phase 1 backup local provider、external API // integration)直接複用。 func StripBearerPrefix(authHeader string) string { const prefix = "Bearer " if strings.HasPrefix(authHeader, prefix) { return strings.TrimPrefix(authHeader, prefix) } return authHeader }