jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:

- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
  (tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
  WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
  - internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
  - internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
    防 session fixation, OWASP ASVS V3.2.1)
  - 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
  - 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
  - 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
    ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
  - OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
    (AuthStyleInParams 強制 token endpoint 不送 client_secret)
  - 預留 ServiceClient* 欄位給未來 client_credentials grant
  - 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
    (Audit C1:multi-tenant 隔離破口)
  - Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
  - 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:21:20 +08:00

281 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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。
//
// OB52026-04-26OIDC 是唯一認證路徑:
// - 從 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 各一個 cookieOB2 / 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 換 IDFix-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 也放進 contexthandler如 /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 已經寫了 responsec.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 安全轉成 stringgin 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 authPhase 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
}