jim800121chen c2f0b1549e feat: OIDC 登入修復(email fallback / prompt=login / logout 連動)+ 真轉檔鏈路 e2e
接 DB 後真人 OIDC 登入暴露 MC OIDC provider 實作不完整,visionA 端逐項繞過,
讓登入/換帳號可用;另補真轉檔服務的整合 e2e。

OIDC 登入修復(MC 端根因另有交接檔,visionA 先繞過):
- email fallback:MC id_token 不發 email claim(ASP.NET Identity 預設 factory 只發
  sub/name)→ A7 email 必填擋住登入。callback email 空時用 <sub>@noemail.visiona.local
  placeholder,不污染 schema,MC 修好發真 email 後 ON CONFLICT 自動覆寫
- prompt=login:authorize 帶 prompt=login(config VISIONA_OIDC_PROMPT_LOGIN,預設關)
- logout 連動 MC:logout 回 idp_logout(MC Web :7880 /account/logout,GET),前端用
  隱藏 iframe 觸發清 MC session(Web/Api 共享 DataProtection)→ 能換帳號。
  config VISIONA_OIDC_LOGOUT_URL、向下相容(未設則只清本地)

真轉檔鏈路 e2e(//go:build realconv,按需對 stage 跑、不污染主測試集):
- real_converter_e2e:give 真轉檔服務 contract(init→poll→completed/promote/result)
- real_chain_e2e:真轉檔→PromoteToModels→model 進 PG→冪等 全鏈路(對 stage 跑 PASS)

交接檔(給對應團隊根治):
- mc-email-claim-handoff:MC 加 email claim(自訂 UserClaimsPrincipalFactory)
- converter-promote-oauth-handoff:轉檔服務 OAuth 用 form body 非 Basic Auth

全程 Reviewer 審查 + 對 stage 真環境驗證。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 03:18:59 +08:00

253 lines
12 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 實作 api-server 的 REST + WebSocket 入口。
//
// 對齊 `.autoflow/04-architecture/api/api-spec.md`。
//
// **B4 範圍**Router / Middleware / 結構化錯誤回應骨架 + 少數 handler/healthz、
// /api/system/health、/api/system/info、/api/pairing/token、/api/pairing/status
//
// **B5 範圍**(本檔):
// - Authlogin / logout / mestubregister → 501
// - Pairinglist tokens / revoke token
// - Deviceslist / get讀雲端 repo + 合併 tunnel 狀態scan / connect /
// disconnect / flash / inference.start/stop 走 proxyunpair 軟刪
// - Modelslist / get / init upload / finalize / delete
// - System/system/deps走 proxy
// - ClustersGET /clusters 回空陣列;其他 stub
// - Storage/storage/* 的 LocalFS 假 presigned URL 代理GET/PUT
// - WebSocket保留 501 stub詳見 stubs.goB7 TODO
package api
import (
"log/slog"
"github.com/gin-gonic/gin"
"visiona-backend/internal/auth"
"visiona-backend/internal/conversion"
"visiona-backend/internal/converter"
"visiona-backend/internal/device"
"visiona-backend/internal/fileaccess"
"visiona-backend/internal/model"
"visiona-backend/internal/oidc"
"visiona-backend/internal/session"
"visiona-backend/internal/storage"
"visiona-backend/internal/user"
"visiona-backend/internal/usersession"
)
// Deps 匯整 api router 所需的所有依賴;由 cmd/api-server/main.go 在啟動時注入。
//
// 之所以集中在一個 struct是為了
// 1. 讓 NewRouter 簽章穩定(之後加新依賴只改 struct不破壞既有 caller
// 2. integration test 容易組裝(只需建一個 Deps 物件)
// 3. 各 handler 透過 closure 取得依賴,不需要 global state
//
// OB52026-04-26起 OIDC 是唯一認證路徑OIDCProvider + SessionManager 必填,
// validate() 在 NewRouter 啟動時就會 panic 提前暴露 misconfiguration。
type Deps struct {
Logger *slog.Logger
// PairingStore 管理 Pairing Token 生命週期。為 nil 時 /api/pairing/* 會回 501。
PairingStore auth.PairingStore
// ─── OIDCOB5 起為必填) ───
// OIDCProvider 封裝 OIDC clientauthorization URL 組裝、token exchange、id_token 驗證)。
OIDCProvider oidc.Provider
// SessionManager 管理 cookie sessionStartSession / GetSession / EndSession
SessionManager *usersession.Manager
// OIDCPostLoginURL 是 callback 完成後 302 回 frontend 的 base URL。
// 例http://localhost:3000dev/ https://app.visiona.cloudprod
// 為空字串時 callback handler 會 fallback 到 same-origin "/"(不建議生產配置)。
OIDCPostLoginURL string
// OIDCLogoutURL 是「讓使用者連帶登出 IdPMember Centersession」的入口 URL。
//
// 背景MC 不支援標準 OIDC RP-initiated logout唯一瀏覽器可觸發的登出是
// MemberCenter.Web:7880的 /account/logoutGET 即 302。詳見 config.OIDCConfig.LogoutURL。
//
// 非空時logout handleroidc_auth.go會在 LogoutResponse 多回 idp_logout 欄位,
// 由前端 navigate 觸發 MC 登出。為空時不回該欄位、維持「只清本地 session」舊行為。
// 對齊 cfg.OIDC.LogoutURLenv VISIONA_OIDC_LOGOUT_URL
OIDCLogoutURL string
// UserStore 在 OIDC callback 驗 id_token 成功後 provisionupsert一筆 users 列
// DB-on FK 收尾,問題 #1。D1-BOIDC sub 直接當 users.idMember Center sub 為 UUID
//
// 為何必須DB-on有 FK真人登入後任何帶 owner_user_id FK 的寫入(上傳 model、配對、
// 發 pairing token都需要 users 表已有對應列;不 upsert → FK violation。
//
// 為 nil 時 callback 略過 upsert最小骨架 / 純 OIDC unit test 不強制注入);
// main.go 依 dbPool 注入 PostgresStore 或 InMemoryStore兩模式都會 upsert 對齊行為。
UserStore user.Store
SessionStore session.Store
Forwarder *session.Forwarder
DeviceRepo device.Repository
ModelRepo model.Repository
// DeviceUnpairer 將「軟刪 device + cascade 撤銷其 pairing/session token」包成單一原子
// Postgres tx或一致in-memory 依序操作DB 接入塊 5.2database.md §6
// 為 nil 時 unpair handler fallback 到「只軟刪 device、不 cascade」的舊行為
//(見 devices.go確保最小骨架仍可啟動。main.go 依 dbPool 是否非 nil 擇一注入。
DeviceUnpairer DeviceUnpairer
Storage storage.Store
Converter converter.Client
// Conversion 是 Phase 0.8 轉檔功能的 Service interface5 個 endpoint 共用)。
// 為 nil 時 /api/conversion/* 5 個 endpoint 全回 501 NOT_IMPLEMENTED
// main.go 在 cfg.Conversion.Enabled() 為 false 時不 wire對齊 api-conversion.md。
//
// 設計選擇:用 conversion.Service interface 而非 concrete type — 方便 unit test 注入 stub。
Conversion conversion.Service
// FileAccessIssuer 是 Phase 0.9「模型庫 model 直連 FAA 下載」的 download token 簽發者
// ADR-017 (a))。為 nil 時 GET /api/models/:id/download 回 501 NOT_IMPLEMENTED
// main.go 在 cfg.FileAccess.Enabled() 為 false 時不 wire
// 用 interface 方便 unit test 注入 fake。
FileAccessIssuer fileaccess.DownloadTokenIssuer
// FAABaseURL 是 File Access Agent 對外 base URL不帶結尾斜線用來組回給 Client
// 的 download_url`{FAABaseURL}/files/{object_key}`)。
// 由 cfg.FileAccess.FAABaseURL 注入FileAccessIssuer 非 nil 時必非空main.go 確保)。
FAABaseURL string
// CORSAllowedOrigins 是允許的瀏覽器 Origin 白名單;空 slice 預設放行
// http://localhost:3000前端 dev server
CORSAllowedOrigins []string
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
// StaticUserID 欄位已移除multi-tenant 環境下 fallback 到固定 user 是 latent multi-user
// 隔離破口OWASP A01 + A04。改 OIDC 後 AuthMiddleware 會擋下未登入請求,
// handler 拿不到 UserContext 一律 500safer than silent fallback
// dev seed / unit test 仍可獨立讀 cfg.Auth.StaticUserID env不再注入 Deps。
// MaxUploadSizeMB 是模型上傳大小上限MB0 代表不限(測試友善)。
// 對齊 feature-model-management.mdPhase 0 預設 100 MB由 config.Model.MaxSizeMB 注入)。
MaxUploadSizeMB int
// SessionTokenStore 保存 Pairing → Session 交換後發出的 Session Token。
// Phase 0 雛形用 in-memory 實作(由 main.go 注入Phase 1 改為 DB-backed。
// 為 nil 時 /api/pairing/exchange 會回 501 NOT_IMPLEMENTED。
SessionTokenStore auth.SessionTokenStore
// RelayPublicURL 是 agent 連 tunnel 用的 WSS URL對外可訪問
// 由 `POST /api/pairing/exchange` 回給 agent若為空會回預設 `wss://relay.visionA.cloud`(雛形 placeholder
// 對齊 build-deploy.md 的 VISIONA_RELAY_PUBLIC_URL 環境變數。
RelayPublicURL string
// PairingExchanger 在 pairing exchange 時自建一筆 device 並建綁該 device 的 session token
// DB-on FK 收尾,問題 #2session_tokens.device_id NOT NULL FK但雛形流程從不建 device
// - Postgres用 db.WithTx 把「建 device + 建 session token」包成單一交易整筆原子。
// - in-memory依序執行無交易行為一致。
// 為 nil 時 exchange handler fallback 到舊行為(直接用 info.DeviceID 建 session token
// 不自建 device——僅 DB-off 雛形相容用。main.go 依 dbPool 擇一注入。
PairingExchanger PairingExchanger
// HealthDBPool / HealthRedis 是 /healthz 要 ping 的依賴DB 接入塊 5.4)。
// 由 main.go 注入 db.Pool / db.RedisClient皆有 Ping(ctx))。為 nil 代表該依賴未啟用、
// /healthz 略過不檢查in-memory 模式維持「process 活著就 ok」
// 任一非 nil 依賴 ping 失敗 → /healthz 回 503讓 load balancer 拉出此實例。
HealthDBPool HealthPinger
HealthRedis HealthPinger
}
// validate 確認必要欄位都有;在 NewRouter 啟動時呼叫,避免 nil pointer panic 推到 runtime。
//
// 嚴格欄位(缺則 panic — fail fast避免半設定狀態跑進生產
// - OIDCProvider — OB5 起 OIDC 是唯一認證路徑
// - SessionManager — OIDC cookie session 必須
//
// 寬鬆欄位缺有預設Logger / CORSAllowedOrigins
//
// 其他欄位PairingStore / SessionStore 等)若為 nil 不擋 — 個別 handler 會回 501
// 允許「最小骨架」啟動跑 /healthz。
//
// Phase 0.7 security fix C1移除 StaticUserID 預設 "demo-user" 的 fallback。
func (d *Deps) validate() {
if d.Logger == nil {
d.Logger = slog.Default()
}
if len(d.CORSAllowedOrigins) == 0 {
d.CORSAllowedOrigins = []string{"http://localhost:3000"}
}
if d.OIDCProvider == nil {
panic("api.NewRouter: Deps.OIDCProvider is required (OB5: OIDC is the only auth path)")
}
if d.SessionManager == nil {
panic("api.NewRouter: Deps.SessionManager is required (OB5: OIDC cookie session is mandatory)")
}
}
// NewRouter 建立 Gin engine 並註冊所有路由與中介層。
//
// 為何回 *gin.Engine 而非 http.Handlercmd/api-server/main.go 需要 access
// engine.Run也可拿 .Handler() 給標準 http.Server 用,所以這個選擇沒讓
// caller 失去彈性)。
func NewRouter(deps Deps) *gin.Engine {
deps.validate()
// gin 的 ReleaseMode 由 caller 視環境設定cmd/api-server/main.go
// 這裡不主動設,避免測試環境被汙染。
r := gin.New()
// 註冊全域 middleware順序很重要Recovery 第一Logger 接著CORS 之後)
r.Use(RecoveryMiddleware(deps.Logger))
r.Use(RequestIDMiddleware())
r.Use(LoggerMiddleware(deps.Logger))
r.Use(CORSMiddleware(deps.CORSAllowedOrigins))
r.Use(ErrorMiddleware()) // 統一把 c.Errors 轉成 JSON
// /healthz 不需要 auth — K8s liveness/readiness 用。
// 塊 5.4啟用的依賴Postgres / Redis都 ping任一失敗回 503fail-fast
r.GET("/healthz", HealthzHandler(HealthDeps{
DBPool: deps.HealthDBPool,
Redis: deps.HealthRedis,
Logger: deps.Logger,
}))
// /storage/* 不走 AuthMiddleware改用 HMAC 簽章)— 對齊 api-spec.md §10
registerStorageRoutes(r, deps)
// /api/pairing/exchange 刻意不走 AuthMiddleware
// agent 尚未有 session token 時就得用 Pairing Token 換 Session Token
// Pairing Token 本身就是這個 endpoint 的憑證。詳見 security.md §1.2。
registerPairingPublicRoutes(r, deps)
// /ws/* 雛形全部 501B7 補齊 WebSocket proxy
registerWebSocketStubs(r)
// OIDC public routes不走 AuthMiddleware
// - GET /api/auth/login — 起始登入流程user 還沒登入)
// - GET /api/auth/callback — OIDC IdP 302 回來
// 必須註冊在 AuthMiddleware 群組之外,否則使用者沒登入根本進不去。
registerOIDCPublicRoutes(r, deps)
// /api 群組:所有路由都走 OIDC AuthMiddlewarecookie session → UserContext
apiGroup := r.Group("/api")
apiGroup.Use(AuthMiddleware(deps))
// B4 核心
registerSystemRoutes(apiGroup, deps)
registerPairingRoutes(apiGroup, deps)
// B5 新增:實際 handler
registerAuthRoutes(apiGroup, deps)
registerDeviceRoutes(apiGroup, deps)
registerModelRoutes(apiGroup, deps)
registerClusterRoutes(apiGroup, deps)
// Phase 0.8Conversion轉檔— 5 個 endpoint
// 對齊 .autoflow/04-architecture/api/api-conversion.md
registerConversionRoutes(apiGroup, deps)
// Stubs只註冊「還沒有實際 handler」的那些 endpoint
registerStubRoutes(apiGroup, deps)
return r
}