從 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>
281 lines
9.5 KiB
Go
281 lines
9.5 KiB
Go
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
|
||
}
|